diff --git a/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg b/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg
new file mode 100644
index 000000000000..43501efd70c5
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index e220c17feb57..a4b9e8cb4096 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -4244,6 +4244,45 @@ const CONST = {
TAX: 'tax',
},
},
+ SPEND_RULES: {
+ CATEGORIES: {
+ AIRLINES: 'airlines',
+ ALCOHOL_AND_BARS: 'alcoholAndBars',
+ AMAZON_AND_BOOKSTORES: 'amazonAndBookstores',
+ AUTOMOTIVE: 'automotive',
+ CAR_RENTALS: 'carRentals',
+ DINING: 'dining',
+ FUEL_AND_GAS: 'fuelAndGas',
+ GOVERNMENT_AND_NON_PROFITS: 'governmentAndNonProfits',
+ GROCERIES: 'groceries',
+ GYMS_AND_FITNESS: 'gymsAndFitness',
+ HEALTHCARE: 'healthcare',
+ HOTELS: 'hotels',
+ INTERNET_AND_PHONE: 'internetAndPhone',
+ OFFICE_SUPPLIES: 'officeSupplies',
+ PARKING_AND_TOLLS: 'parkingAndTolls',
+ PROFESSIONAL_SERVICES: 'professionalServices',
+ RETAIL: 'retail',
+ SHIPPING_AND_DELIVERY: 'shippingAndDelivery',
+ SOFTWARE: 'software',
+ TRANSIT_AND_RIDESHARE: 'transitAndRideshare',
+ TRAVEL_AGENCIES: 'travelAgencies',
+ },
+ FORM: {
+ FIELDS: {
+ CARD_IDS: 'cardIDs',
+ RESTRICTION_ACTION: 'restrictionAction',
+ MERCHANT_NAMES: 'merchantNames',
+ MERCHANT_MATCH_TYPES: 'merchantMatchTypes',
+ CATEGORIES: 'categories',
+ MAX_AMOUNT: 'maxAmount',
+ },
+ },
+ ACTION: {
+ ALLOW: 'allow',
+ BLOCK: 'block',
+ },
+ },
get SUBSCRIPTION_PRICES() {
return {
@@ -9207,6 +9246,7 @@ const CONST = {
THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu',
},
RULES: {
+ ADD_SPEND_RULE: 'WorkspaceRules-AddSpendRule',
INDIVIDUAL_EXPENSES_MENU_ITEM: 'WorkspaceRules-IndividualExpensesMenuItem',
SPEND_RULE_ITEM: 'WorkspaceRules-SpendRuleItem',
MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem',
@@ -9216,6 +9256,9 @@ const CONST = {
MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches',
MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete',
CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector',
+ SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem',
+ SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave',
+ SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType',
},
EXPENSIFY_CARD: {
ISSUE_CARD_BUTTON: 'WorkspaceExpensifyCard-IssueCardButton',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index fbe3c4ab40a2..c149e0f0aba9 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1066,6 +1066,8 @@ const ONYXKEYS = {
EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft',
MERCHANT_RULE_FORM: 'merchantRuleForm',
MERCHANT_RULE_FORM_DRAFT: 'merchantRuleFormDraft',
+ SPEND_RULE_FORM: 'spendRuleForm',
+ SPEND_RULE_FORM_DRAFT: 'spendRuleFormDraft',
ADD_DOMAIN_MEMBER_FORM: 'addDomainMemberForm',
ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft',
ADD_WORK_EMAIL_FORM: 'addWorkEmailForm',
@@ -1209,6 +1211,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm;
[ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm;
[ONYXKEYS.FORMS.MERCHANT_RULE_FORM]: FormTypes.MerchantRuleForm;
+ [ONYXKEYS.FORMS.SPEND_RULE_FORM]: FormTypes.SpendRuleForm;
[ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm;
[ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM]: FormTypes.AddWorkEmailForm;
};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 81f3e74ad7ae..337e643ae8b3 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2997,6 +2997,30 @@ const ROUTES = {
route: 'workspaces/:policyID/rules/merchant-rules/new',
getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const,
},
+ RULES_SPEND_NEW: {
+ route: 'workspaces/:policyID/rules/spend-rules/new',
+ getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new` as const,
+ },
+ RULES_SPEND_CARD: {
+ route: 'workspaces/:policyID/rules/spend-rules/new/card',
+ getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/card` as const,
+ },
+ RULES_SPEND_CATEGORY: {
+ route: 'workspaces/:policyID/rules/spend-rules/new/category',
+ getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/category` as const,
+ },
+ RULES_SPEND_MAX_AMOUNT: {
+ route: 'workspaces/:policyID/rules/spend-rules/new/max-amount',
+ getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/max-amount` as const,
+ },
+ RULES_SPEND_MERCHANTS: {
+ route: 'workspaces/:policyID/rules/spend-rules/new/merchants',
+ getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/merchants` as const,
+ },
+ RULES_SPEND_MERCHANT_EDIT: {
+ route: 'workspaces/:policyID/rules/spend-rules/new/merchants/:merchantIndex',
+ getRoute: (policyID: string, merchantIndex: string) => `workspaces/${policyID}/rules/spend-rules/new/merchants/${merchantIndex}` as const,
+ },
RULES_MERCHANT_MERCHANT_TO_MATCH: {
route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant-to-match',
getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant-to-match` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index f03c1f37d738..5a0929788e0d 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -822,6 +822,10 @@ const SCREENS = {
RULES_CUSTOM: 'Rules_Custom',
RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default',
RULES_MERCHANT_NEW: 'Rules_Merchant_New',
+ RULES_SPEND_NEW: 'Rules_Spend_New',
+ RULES_SPEND_CARD: 'Rules_Spend_Card',
+ RULES_SPEND_CATEGORY: 'Rules_Spend_Category',
+ RULES_SPEND_MAX_AMOUNT: 'Rules_Spend_Max_Amount',
RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match',
RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type',
RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant',
@@ -833,6 +837,8 @@ const SCREENS = {
RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable',
RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches',
RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit',
+ RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants',
+ RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit',
PER_DIEM: 'Per_Diem',
PER_DIEM_IMPORT: 'Per_Diem_Import',
PER_DIEM_IMPORTED: 'Per_Diem_Imported',
diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts
index fd232f9afa7d..910d4c9615d4 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -135,6 +135,7 @@ import Filters from '@assets/images/simple-illustrations/simple-illustration__fi
import FishbowlBlue from '@assets/images/simple-illustrations/simple-illustration__fishbowl--blue.svg';
import Flash from '@assets/images/simple-illustrations/simple-illustration__flash.svg';
import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg';
+import FoodTruck from '@assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg';
import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg';
import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg';
import HeadSet from '@assets/images/simple-illustrations/simple-illustration__headset.svg';
@@ -396,6 +397,7 @@ const Illustrations = {
Target,
Trophy,
Trophy1,
+ FoodTruck,
// Multifactor Authentication Illustrations
MagnifyingGlassSpyMouthClosed,
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index a38baf7bdb37..29912af4be39 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -538,6 +538,7 @@ function BaseSelectionList({
canSelectMultiple={canSelectMultiple}
onSelectAll={handleSelectAll}
headerStyle={style?.listHeaderWrapperStyle}
+ selectAllTextStyle={style?.listHeaderSelectAllTextStyle}
shouldShowSelectAllButton={!!onSelectAll}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
/>
diff --git a/src/components/SelectionList/components/ListHeader.tsx b/src/components/SelectionList/components/ListHeader.tsx
index 976746329dd9..6f3553e3ebfb 100644
--- a/src/components/SelectionList/components/ListHeader.tsx
+++ b/src/components/SelectionList/components/ListHeader.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import type {StyleProp, ViewStyle} from 'react-native';
+import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Checkbox from '@components/Checkbox';
import {PressableWithFeedback} from '@components/Pressable';
@@ -22,6 +22,9 @@ type ListHeaderProps = {
/** Styles for the list header wrapper */
headerStyle?: StyleProp;
+ /** Styles for the "Select all" text (merged after textStrong) */
+ selectAllTextStyle?: StyleProp;
+
/** Function called when the select all button is pressed */
onSelectAll: () => void;
@@ -38,6 +41,7 @@ function ListHeader({
canSelectMultiple,
onSelectAll,
headerStyle,
+ selectAllTextStyle,
shouldShowSelectAllButton,
shouldPreventDefaultFocusOnSelectRow,
}: ListHeaderProps) {
@@ -84,7 +88,7 @@ function ListHeader({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
onMouseDown={handleMouseDown}
>
- {translate('workspace.people.selectAll')}
+ {translate('workspace.people.selectAll')}
)}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index e14207e1461c..7d8cfe24def5 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -204,6 +204,9 @@ type SelectionListStyle = {
/** Styles for the list header wrapper */
listHeaderWrapperStyle?: StyleProp;
+ /** Styles for the default "Select all" label in the list header (merged after textStrong) */
+ listHeaderSelectAllTextStyle?: StyleProp;
+
/** Styles for the title container of the list item */
listItemTitleContainerStyles?: StyleProp;
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 8f411c70e681..1ada26f464d2 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -6813,6 +6813,62 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und
Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu schützen.`,
},
+ addSpendRule: 'Ausgaberegel hinzufügen',
+ cardPageTitle: 'Karte',
+ cardsSectionTitle: 'Karten',
+ chooseCards: 'Karten auswählen',
+ saveRule: 'Regel speichern',
+ allow: 'Erlauben',
+ spendRuleSectionTitle: 'Ausgabenregel',
+ restrictionType: 'Beschränkungstyp',
+ restrictionTypeHelpAllow: 'Ausgaben werden genehmigt, wenn sie einem beliebigen Händler oder einer Kategorie entsprechen und einen Höchstbetrag nicht überschreiten.',
+ restrictionTypeHelpBlock: 'Buchungen werden abgelehnt, wenn sie mit einem Händler oder einer Kategorie übereinstimmen oder einen Höchstbetrag überschreiten.',
+ addMerchant: 'Händler hinzufügen',
+ merchantContains: 'Händler enthält',
+ merchantExactlyMatches: 'Händler stimmt exakt überein',
+ noBlockedMerchants: 'Keine blockierten Händler',
+ addMerchantToBlockSpend: 'Händler hinzufügen, um Ausgaben zu blockieren',
+ noAllowedMerchants: 'Keine erlaubten Händler',
+ addMerchantToAllowSpend: 'Fügen Sie einen Händler hinzu, um Ausgaben zu erlauben',
+ matchType: 'Übereinstimmungstyp',
+ matchTypeContains: 'Enthält',
+ matchTypeExact: 'Stimmt genau überein',
+ spendCategory: 'Ausgabenkategorie',
+ maxAmount: 'Maximalbetrag',
+ maxAmountHelp: 'Jede Belastung über diesem Betrag wird abgelehnt, unabhängig von Händler- und Ausgabenkategoriebeschränkungen.',
+ currencyMismatchTitle: 'Währungsinkonsistenz',
+ currencyMismatchPrompt: 'Um einen Höchstbetrag festzulegen, wählen Sie Karten aus, die in derselben Währung abgerechnet werden.',
+ reviewSelectedCards: 'Ausgewählte Karten prüfen',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} weitere`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Wenden Sie mindestens eine Ausgabenregel auf eine Karte an',
+ confirmErrorCardRequired: 'Feld „Karte“ ist erforderlich',
+ confirmErrorApplyAtLeastOneSpendRule: 'Wenden Sie mindestens eine Ausgabenregel an',
+ categories: 'Kategorien',
+ merchants: 'Händler',
+ max: 'Max',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Fluggesellschaften',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alkohol und Bars',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon und Buchhandlungen',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automobilindustrie',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Mietwagen',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Essen',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Kraftstoff und Gas',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Regierung und gemeinnützige Organisationen',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Lebensmittel',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Fitnessstudios und Fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Gesundheitswesen',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotels',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet und Telefon',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Bürobedarf',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parken und Mautgebühren',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Professionelle Dienstleistungen',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Einzelhandel',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Versand und Lieferung',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Öffentlicher Nahverkehr und Fahrgemeinschaften',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Reisebüros',
+ },
},
},
planTypePage: {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index a5867646e848..1640c05cac50 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -6768,6 +6768,62 @@ const translations = {
title: 'Expensify Cards offer built-in protection - always',
description: `Expensify always declines these charges:\n\n • Adult services\n • ATMs\n • Gambling\n • Money transfers\n\nAdd more spend rules to protect company cash flow.`,
},
+ addSpendRule: 'Add spend rule',
+ cardPageTitle: 'Card',
+ cardsSectionTitle: 'Cards',
+ chooseCards: 'Choose cards',
+ saveRule: 'Save rule',
+ allow: 'Allow',
+ spendRuleSectionTitle: 'Spend rule',
+ restrictionType: 'Restriction type',
+ restrictionTypeHelpAllow: "Charges are approved if they match any merchant or category, and don't exceed a max amount.",
+ restrictionTypeHelpBlock: 'Charges are declined if they match any merchant or category, or exceed a max amount.',
+ addMerchant: 'Add merchant',
+ merchantContains: 'Merchant contains',
+ merchantExactlyMatches: 'Merchant exactly matches',
+ noBlockedMerchants: 'No blocked merchants',
+ addMerchantToBlockSpend: 'Add a merchant to block spend',
+ noAllowedMerchants: 'No allowed merchants',
+ addMerchantToAllowSpend: 'Add a merchant to allow spend',
+ matchType: 'Match type',
+ matchTypeContains: 'Contains',
+ matchTypeExact: 'Matches exactly',
+ spendCategory: 'Spend category',
+ maxAmount: 'Max amount',
+ maxAmountHelp: 'Any charge over this amount will be declined, regardless of merchant and spend category restrictions.',
+ currencyMismatchTitle: 'Currency mismatch',
+ currencyMismatchPrompt: 'To set a max amount, select cards that settle in the same currency.',
+ reviewSelectedCards: 'Review selected cards',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} more`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Apply at least one spend rule to one card',
+ confirmErrorCardRequired: 'Card is a required field',
+ confirmErrorApplyAtLeastOneSpendRule: 'Apply at least one spend rule',
+ categories: 'Categories',
+ merchants: 'Merchants',
+ max: 'Max',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Airlines',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcohol and bars',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon and bookstores',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotive',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Car rentals',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Dining',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Fuel and gas',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Government and non-profits',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Groceries',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Gyms and fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Healthcare',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotels',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet and phone',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Office supplies',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking and tolls',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Professional services',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Retail',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Shipping and delivery',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transit and rideshare',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Travel agencies',
+ },
},
},
planTypePage: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index ae51e8001082..9745520ac489 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -6643,6 +6643,62 @@ ${amount} para ${merchant} - ${date}`,
title: 'Las tarjetas Expensify ofrecen protección integrada, siempre',
description: `Expensify siempre rechaza estos cargos:\n\n • Servicios para adultos\n • Cajeros automáticos\n • Juegos de azar\n • Transferencias de dinero\n\nAgregue más reglas de gasto para proteger el flujo de caja de la empresa.`,
},
+ addSpendRule: 'Añadir regla de gastos',
+ cardPageTitle: 'Tarjeta',
+ cardsSectionTitle: 'Tarjetas',
+ chooseCards: 'Elegir tarjetas',
+ saveRule: 'Guardar regla',
+ allow: 'Permitir',
+ spendRuleSectionTitle: 'Regla de gastos',
+ restrictionType: 'Tipo de restricción',
+ restrictionTypeHelpAllow: 'Los cargos se aprueban si coinciden con cualquier comerciante o categoría, y no superan un importe máximo.',
+ restrictionTypeHelpBlock: 'Los cargos se rechazan si coinciden con cualquier comerciante o categoría, o superan un importe máximo.',
+ addMerchant: 'Añadir comerciante',
+ merchantContains: 'El comerciante contiene',
+ merchantExactlyMatches: 'El comerciante coincide exactamente',
+ noBlockedMerchants: 'No hay comerciantes bloqueados',
+ addMerchantToBlockSpend: 'Añade un comerciante para bloquear gastos',
+ noAllowedMerchants: 'No hay comerciantes permitidos',
+ addMerchantToAllowSpend: 'Añade un comerciante para permitir gastos',
+ matchType: 'Tipo de coincidencia',
+ matchTypeContains: 'Contiene',
+ matchTypeExact: 'Coincide exactamente',
+ spendCategory: 'Categoría de gasto',
+ maxAmount: 'Importe máximo',
+ maxAmountHelp: 'Cualquier cargo por encima de este importe se rechazará, independientemente de las restricciones de comerciante y categoría de gasto.',
+ currencyMismatchTitle: 'Moneda no coincide',
+ currencyMismatchPrompt: 'Para establecer un importe máximo, selecciona tarjetas que se liquiden en la misma moneda.',
+ reviewSelectedCards: 'Revisar tarjetas seleccionadas',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} más`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Aplica al menos una regla de gasto a una tarjeta',
+ confirmErrorCardRequired: 'La tarjeta es un campo obligatorio',
+ confirmErrorApplyAtLeastOneSpendRule: 'Aplica al menos una regla de gasto',
+ categories: 'Categorías',
+ merchants: 'Comerciantes',
+ max: 'Máx.',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Aerolíneas',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Bares y restaurantes',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon y librerías',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotriz',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Alquiler de coches',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Comida y bebida',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Combustible y gasolina',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Gobierno y organizaciones sin ánimo de lucro',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Supermercados',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Gimnasios y fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Salud',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hoteles',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet y teléfono',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Oficina',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking y peajes',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Servicios profesionales',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Tiendas',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Envío y entrega',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transporte y transporte compartido',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agencias de viajes',
+ },
},
},
},
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index ae6196763821..b90ba479851a 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -6836,6 +6836,62 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip
Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’entreprise.`,
},
+ addSpendRule: 'Ajouter une règle de dépense',
+ cardPageTitle: 'Carte',
+ cardsSectionTitle: 'Cartes',
+ chooseCards: 'Choisir des cartes',
+ saveRule: 'Enregistrer la règle',
+ allow: 'Autoriser',
+ spendRuleSectionTitle: 'Règle de dépense',
+ restrictionType: 'Type de restriction',
+ restrictionTypeHelpAllow: 'Les frais sont approuvés s’ils correspondent à n’importe quel commerçant ou catégorie et ne dépassent pas un montant maximal.',
+ restrictionTypeHelpBlock: 'Les frais sont refusés s’ils correspondent à un commerçant ou à une catégorie, ou s’ils dépassent un montant maximal.',
+ addMerchant: 'Ajouter un commerçant',
+ merchantContains: 'Le commerçant contient',
+ merchantExactlyMatches: 'Le commerçant correspond exactement',
+ noBlockedMerchants: 'Aucun commerçant bloqué',
+ addMerchantToBlockSpend: 'Ajouter un commerçant pour bloquer les dépenses',
+ noAllowedMerchants: 'Aucun commerçant autorisé',
+ addMerchantToAllowSpend: 'Ajouter un commerçant pour autoriser les dépenses',
+ matchType: 'Type de correspondance',
+ matchTypeContains: 'Contient',
+ matchTypeExact: 'Correspond exactement',
+ spendCategory: 'Catégorie de dépense',
+ maxAmount: 'Montant maximal',
+ maxAmountHelp: 'Toute transaction supérieure à ce montant sera refusée, indépendamment des restrictions liées au commerçant et à la catégorie de dépense.',
+ currencyMismatchTitle: 'Incompatibilité de devise',
+ currencyMismatchPrompt: 'Pour définir un montant maximal, sélectionnez des cartes qui sont réglées dans la même devise.',
+ reviewSelectedCards: 'Examiner les cartes sélectionnées',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} de plus`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Appliquez au moins une règle de dépense à une carte',
+ confirmErrorCardRequired: 'La carte est un champ obligatoire',
+ confirmErrorApplyAtLeastOneSpendRule: 'Appliquez au moins une règle de dépense',
+ categories: 'Catégories',
+ merchants: 'Commerçants',
+ max: 'Max',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Compagnies aériennes',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcool et bars',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon et les librairies',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automobile',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Locations de voiture',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Restauration',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Carburant et gaz',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Secteur public et organisations à but non lucratif',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Courses',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Salles de sport et fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Santé',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hôtels',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet et téléphone',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Fournitures de bureau',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking et péages',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Services professionnels',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Vente au détail',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Expédition et livraison',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Logiciel',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transports en commun et VTC',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agences de voyages',
+ },
},
},
planTypePage: {
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 21b0d587e97e..5e1f41988eb9 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1071,15 +1071,12 @@ const translations: TranslationDeepObject = {
if (!added && !updated) {
return 'Nessuna categoria è stata aggiunta o aggiornata.';
}
-
if (added && updated) {
return `${added} ${added === 1 ? 'categoria aggiunta' : 'categorie aggiunte'}, ${updated} ${updated === 1 ? 'categoria aggiornata' : 'categorie aggiornate'}.`;
}
-
if (added) {
return added === 1 ? 'È stata aggiunta 1 categoria.' : `Sono state aggiunte ${added} categorie.`;
}
-
return updated === 1 ? 'È stata aggiornata 1 categoria.' : `Sono state aggiornate ${updated} categorie.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
@@ -6802,6 +6799,62 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo
Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
},
+ addSpendRule: 'Aggiungi regola di spesa',
+ cardPageTitle: 'Carta',
+ cardsSectionTitle: 'Carte',
+ chooseCards: 'Scegli le carte',
+ saveRule: 'Salva regola',
+ allow: 'Consenti',
+ spendRuleSectionTitle: 'Regola di spesa',
+ restrictionType: 'Tipo di restrizione',
+ restrictionTypeHelpAllow: 'Le spese vengono approvate se corrispondono a qualsiasi venditore o categoria e non superano un importo massimo.',
+ restrictionTypeHelpBlock: 'Le transazioni vengono rifiutate se corrispondono a un esercente o a una categoria, oppure se superano un importo massimo.',
+ addMerchant: 'Aggiungi esercente',
+ merchantContains: 'Il commerciante contiene',
+ merchantExactlyMatches: 'Il commerciante corrisponde esattamente',
+ noBlockedMerchants: 'Nessun esercente bloccato',
+ addMerchantToBlockSpend: 'Aggiungi un esercente da bloccare',
+ noAllowedMerchants: 'Nessun esercente consentito',
+ addMerchantToAllowSpend: 'Aggiungi un esercente per consentire la spesa',
+ matchType: 'Tipo di corrispondenza',
+ matchTypeContains: 'Contiene',
+ matchTypeExact: 'Corrisponde esattamente',
+ spendCategory: 'Categoria di spesa',
+ maxAmount: 'Importo massimo',
+ maxAmountHelp: 'Qualsiasi addebito superiore a questo importo verrà rifiutato, indipendentemente dalle restrizioni sul commerciante e sulla categoria di spesa.',
+ currencyMismatchTitle: 'Valuta non corrispondente',
+ currencyMismatchPrompt: 'Per impostare un importo massimo, seleziona carte che si regolano nella stessa valuta.',
+ reviewSelectedCards: 'Controlla le carte selezionate',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} altro`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Applica almeno una regola di spesa a una carta',
+ confirmErrorCardRequired: 'Il campo Carta è obbligatorio',
+ confirmErrorApplyAtLeastOneSpendRule: 'Applica almeno una regola di spesa',
+ categories: 'Categorie',
+ merchants: 'Esercenti',
+ max: 'Massimo',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Compagnie aeree',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcol e bar',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon e librerie',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotive',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Noleggio auto',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Ristoranti',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Carburante e gas',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Pubblica amministrazione e non profit',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Spesa supermercato',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Palestre e fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Sanità',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotel',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet e telefono',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: "Materiale d'ufficio",
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parcheggi e pedaggi',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Servizi professionali',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Vendita al dettaglio',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Spedizione e consegna',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Trasporti e ride sharing',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agenzie di viaggio',
+ },
},
},
planTypePage: {
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index eccda8fc9a4f..0694dcfb6db8 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1054,15 +1054,12 @@ const translations: TranslationDeepObject = {
if (!added && !updated) {
return 'カテゴリーは追加も更新もされていません。';
}
-
if (added && updated) {
return `${added}件のカテゴリーを追加し、${updated}件のカテゴリーを更新しました。`;
}
-
if (added) {
return added === 1 ? 'カテゴリーを1件追加しました。' : `${added}件のカテゴリーを追加しました。`;
}
-
return updated === 1 ? 'カテゴリーを1件更新しました。' : `${updated}件のカテゴリーを更新しました。`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
@@ -6727,6 +6724,62 @@ ${reportName}
会社のキャッシュフローを守るために、支出ルールをさらに追加しましょう。`,
},
+ addSpendRule: '支出ルールを追加',
+ cardPageTitle: 'カード',
+ cardsSectionTitle: 'カード',
+ chooseCards: 'カードを選択',
+ saveRule: 'ルールを保存',
+ allow: '許可',
+ spendRuleSectionTitle: '支出ルール',
+ restrictionType: '制限タイプ',
+ restrictionTypeHelpAllow: 'いずれかの加盟店またはカテゴリに一致し、上限金額を超えない場合、請求は承認されます。',
+ restrictionTypeHelpBlock: '加盟店またはカテゴリに一致するか、上限金額を超えた請求は拒否されます。',
+ addMerchant: '取引先を追加',
+ merchantContains: '加盟店に次を含む',
+ merchantExactlyMatches: '完全一致する加盟店',
+ noBlockedMerchants: 'ブロックされている加盟店はありません',
+ addMerchantToBlockSpend: '支出をブロックする加盟店を追加',
+ noAllowedMerchants: '許可された加盟店はありません',
+ addMerchantToAllowSpend: '支出を許可する加盟店を追加',
+ matchType: 'マッチタイプ',
+ matchTypeContains: '含む',
+ matchTypeExact: '完全一致',
+ spendCategory: '支出カテゴリ',
+ maxAmount: '最大金額',
+ maxAmountHelp: '加盟店や支出カテゴリの制限にかかわらず、この金額を超えるすべての支払いは拒否されます。',
+ currencyMismatchTitle: '通貨の不一致',
+ currencyMismatchPrompt: '上限金額を設定するには、同じ通貨で清算されるカードを選択してください。',
+ reviewSelectedCards: '選択したカードを確認',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}、ほか +${count} 件`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: '少なくとも1つの支出ルールを1枚のカードに適用してください',
+ confirmErrorCardRequired: 'カードは必須項目です',
+ confirmErrorApplyAtLeastOneSpendRule: '少なくとも 1 つの支出ルールを適用してください',
+ categories: 'カテゴリ',
+ merchants: '加盟店',
+ max: '最大',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: '航空会社',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: '酒類とバー',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon と書店',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: '自動車',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'レンタカー',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: '外食',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: '燃料・ガス',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: '政府機関・非営利団体',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: '食料品',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'ジム・フィットネス',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: '医療',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'ホテル',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'インターネットと電話',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: '事務用品',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: '駐車料金と通行料金',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'プロフェッショナルサービス',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: '小売業',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: '配送と配達',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'ソフトウェア',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: '交通機関とライドシェア',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: '旅行代理店',
+ },
},
},
planTypePage: {
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 7336d6d4a873..34a1717024af 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1070,15 +1070,12 @@ const translations: TranslationDeepObject = {
if (!added && !updated) {
return 'Er zijn geen categorieën toegevoegd of bijgewerkt.';
}
-
if (added && updated) {
return `${added} ${added === 1 ? 'categorie' : 'categorieën'} toegevoegd, ${updated} ${updated === 1 ? 'categorie' : 'categorieën'} bijgewerkt.`;
}
-
if (added) {
return added === 1 ? '1 categorie is toegevoegd.' : `${added} categorieën zijn toegevoegd.`;
}
-
return updated === 1 ? '1 categorie is bijgewerkt.' : `${updated} categorieën zijn bijgewerkt.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
@@ -6783,6 +6780,62 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar
Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
},
+ addSpendRule: 'Uitgaveregel toevoegen',
+ cardPageTitle: 'Kaart',
+ cardsSectionTitle: 'Kaarten',
+ chooseCards: 'Kaarten kiezen',
+ saveRule: 'Regel opslaan',
+ allow: 'Toestaan',
+ spendRuleSectionTitle: 'Bestedingsregel',
+ restrictionType: 'Restrictietype',
+ restrictionTypeHelpAllow: 'Kosten worden goedgekeurd als ze overeenkomen met een handelaar of categorie en een maximumbedrag niet overschrijden.',
+ restrictionTypeHelpBlock: 'Betalingen worden geweigerd als ze overeenkomen met een handelaar of categorie, of een maximumbedrag overschrijden.',
+ addMerchant: 'Handelaar toevoegen',
+ merchantContains: 'Handelaar bevat',
+ merchantExactlyMatches: 'Handelaar komt exact overeen',
+ noBlockedMerchants: 'Geen geblokkeerde handelaren',
+ addMerchantToBlockSpend: 'Voeg een handelaar toe om uitgaven te blokkeren',
+ noAllowedMerchants: 'Geen toegestane handelaren',
+ addMerchantToAllowSpend: 'Voeg een handelaar toe om uitgaven toe te staan',
+ matchType: 'Overeenkomsttype',
+ matchTypeContains: 'Bevat',
+ matchTypeExact: 'Komt exact overeen',
+ spendCategory: 'Uitgavencategorie',
+ maxAmount: 'Maximumbedrag',
+ maxAmountHelp: 'Elke betaling boven dit bedrag wordt geweigerd, ongeacht beperkingen voor handelaar en uitgavencategorie.',
+ currencyMismatchTitle: 'Valutamismatch',
+ currencyMismatchPrompt: 'Om een maximumbedrag in te stellen, selecteer je kaarten die in dezelfde valuta worden vereffend.',
+ reviewSelectedCards: 'Geselecteerde kaarten bekijken',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} meer`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Pas minstens één bestedingsregel toe op één kaart',
+ confirmErrorCardRequired: 'Kaart is een verplicht veld',
+ confirmErrorApplyAtLeastOneSpendRule: 'Pas minstens één bestedingsregel toe',
+ categories: 'Categorieën',
+ merchants: 'Handelaars',
+ max: 'Max',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Luchtvaartmaatschappijen',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcohol en bars',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon en boekhandels',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Auto-industrie',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Autoverhuur',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Eten en drinken',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Brandstof en gas',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Overheid en non-profitorganisaties',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Boodschappen',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Sportscholen en fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Gezondheidszorg',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotels',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet en telefoon',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Kantoorbenodigdheden',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parkeren en tol',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Professionele diensten',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Detailhandel',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Verzending en levering',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Openbaar vervoer en ritdiensten',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Reisbureaus',
+ },
},
},
planTypePage: {
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index c9b6fed1f72a..1b3b31d31fcb 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1071,15 +1071,12 @@ const translations: TranslationDeepObject = {
if (!added && !updated) {
return 'Nie dodano ani nie zaktualizowano żadnych kategorii.';
}
-
if (added && updated) {
return `Dodano ${added} ${added === 1 ? 'kategorię' : 'kategorie'}, zaktualizowano ${updated} ${updated === 1 ? 'kategorię' : 'kategorie'}.`;
}
-
if (added) {
return added === 1 ? 'Dodano 1 kategorię.' : `Dodano ${added} kategorie.`;
}
-
return updated === 1 ? 'Zaktualizowano 1 kategorię.' : `Zaktualizowano ${updated} kategorie.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `Dodano ${transactions} transakcji.` : 'Dodano 1 transakcję.'),
@@ -6774,6 +6771,62 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i
Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
},
+ addSpendRule: 'Dodaj regułę wydatków',
+ cardPageTitle: 'Karta',
+ cardsSectionTitle: 'Karty',
+ chooseCards: 'Wybierz karty',
+ saveRule: 'Zapisz regułę',
+ allow: 'Zezwól',
+ spendRuleSectionTitle: 'Reguła wydatków',
+ restrictionType: 'Typ ograniczenia',
+ restrictionTypeHelpAllow: 'Obciążenia są zatwierdzane, jeśli pasują do jakiegokolwiek sprzedawcy lub kategorii i nie przekraczają maksymalnej kwoty.',
+ restrictionTypeHelpBlock: 'Obciążenia są odrzucane, jeśli pasują do jakiegokolwiek sprzedawcy lub kategorii albo przekraczają maksymalną kwotę.',
+ addMerchant: 'Dodaj sprzedawcę',
+ merchantContains: 'Sprzedawca zawiera',
+ merchantExactlyMatches: 'Sprzedawca dokładnie pasuje',
+ noBlockedMerchants: 'Brak zablokowanych sprzedawców',
+ addMerchantToBlockSpend: 'Dodaj sprzedawcę, aby zablokować wydatki',
+ noAllowedMerchants: 'Brak dozwolonych sprzedawców',
+ addMerchantToAllowSpend: 'Dodaj sprzedawcę, aby zezwolić na wydatki',
+ matchType: 'Typ dopasowania',
+ matchTypeContains: 'Zawiera',
+ matchTypeExact: 'Dokładnie pasuje',
+ spendCategory: 'Kategoria wydatków',
+ maxAmount: 'Maksymalna kwota',
+ maxAmountHelp: 'Każda transakcja powyżej tej kwoty zostanie odrzucona, niezależnie od ograniczeń dotyczących sprzedawcy i kategorii wydatków.',
+ currencyMismatchTitle: 'Niezgodność waluty',
+ currencyMismatchPrompt: 'Aby ustawić maksymalną kwotę, wybierz karty rozliczane w tej samej walucie.',
+ reviewSelectedCards: 'Przejrzyj wybrane karty',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} więcej`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Zastosuj co najmniej jedną regułę wydatków do jednej karty',
+ confirmErrorCardRequired: 'Pole „Karta” jest wymagane',
+ confirmErrorApplyAtLeastOneSpendRule: 'Zastosuj co najmniej jedną regułę wydatków',
+ categories: 'Kategorie',
+ merchants: 'Sprzedawcy',
+ max: 'Maks',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Linie lotnicze',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alkohol i bary',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon i księgarnie',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Motoryzacja',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Wypożyczalnie samochodów',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Posiłki',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Paliwo i gaz',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Sektor publiczny i organizacje non-profit',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Zakupy spożywcze',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Siłownie i fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Opieka zdrowotna',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotele',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet i telefon',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Artykuły biurowe',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking i opłaty drogowe',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Usługi profesjonalne',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Sprzedaż detaliczna',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Wysyłka i dostawa',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Oprogramowanie',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transport i przejazdy współdzielone',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Biura podróży',
+ },
},
},
planTypePage: {
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index f43fe751cfe5..2db38568b5de 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1069,15 +1069,12 @@ const translations: TranslationDeepObject = {
if (!added && !updated) {
return 'Nenhuma categoria foi adicionada ou atualizada.';
}
-
if (added && updated) {
return `${added} ${added === 1 ? 'categoria' : 'categorias'} adicionada${added === 1 ? '' : 's'}, ${updated} ${updated === 1 ? 'categoria' : 'categorias'} atualizada${updated === 1 ? '' : 's'}.`;
}
-
if (added) {
return added === 1 ? '1 categoria foi adicionada.' : `${added} categorias foram adicionadas.`;
}
-
return updated === 1 ? '1 categoria foi atualizada.' : `${updated} categorias foram atualizadas.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
@@ -6781,6 +6778,62 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e
Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
},
+ addSpendRule: 'Adicionar regra de gasto',
+ cardPageTitle: 'Cartão',
+ cardsSectionTitle: 'Cartões',
+ chooseCards: 'Escolher cartões',
+ saveRule: 'Salvar regra',
+ allow: 'Permitir',
+ spendRuleSectionTitle: 'Regra de gasto',
+ restrictionType: 'Tipo de restrição',
+ restrictionTypeHelpAllow: 'As cobranças são aprovadas se corresponderem a qualquer comerciante ou categoria e não excederem um valor máximo.',
+ restrictionTypeHelpBlock: 'As cobranças são recusadas se corresponderem a qualquer estabelecimento ou categoria, ou se excederem um valor máximo.',
+ addMerchant: 'Adicionar comerciante',
+ merchantContains: 'Comerciante contém',
+ merchantExactlyMatches: 'Estabelecimento corresponde exatamente',
+ noBlockedMerchants: 'Nenhum comerciante bloqueado',
+ addMerchantToBlockSpend: 'Adicionar um comerciante para bloquear gastos',
+ noAllowedMerchants: 'Nenhum comerciante permitido',
+ addMerchantToAllowSpend: 'Adicione um comerciante para permitir gastos',
+ matchType: 'Tipo de correspondência',
+ matchTypeContains: 'Contém',
+ matchTypeExact: 'Corresponde exatamente',
+ spendCategory: 'Categoria de gasto',
+ maxAmount: 'Valor máximo',
+ maxAmountHelp: 'Qualquer cobrança acima desse valor será recusada, independentemente das restrições de estabelecimento e categoria de gasto.',
+ currencyMismatchTitle: 'Incompatibilidade de moeda',
+ currencyMismatchPrompt: 'Para definir um valor máximo, selecione cartões que liquidem na mesma moeda.',
+ reviewSelectedCards: 'Revisar cartões selecionados',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} mais`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Aplicar pelo menos uma regra de gasto a um cartão',
+ confirmErrorCardRequired: 'O campo Cartão é obrigatório',
+ confirmErrorApplyAtLeastOneSpendRule: 'Aplicar pelo menos uma regra de gasto',
+ categories: 'Categorias',
+ merchants: 'Comerciantes',
+ max: 'Máx.',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Companhias aéreas',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Álcool e bares',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon e livrarias',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotivo',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Aluguel de carros',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Refeições',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Combustível e gás',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Governo e organizações sem fins lucrativos',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Supermercado',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Academias e fitness',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Saúde',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotéis',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet e telefone',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Material de escritório',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Estacionamento e pedágios',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Serviços profissionais',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Varejo',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Envio e entrega',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transporte público e carros de aplicativo',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agências de viagens',
+ },
},
},
planTypePage: {
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 91244efc29fe..e03a218460ca 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -6603,6 +6603,62 @@ ${reportName}
添加更多消费规则以保护公司现金流。`,
},
+ addSpendRule: '添加支出规则',
+ cardPageTitle: '卡',
+ cardsSectionTitle: '卡片',
+ chooseCards: '选择卡片',
+ saveRule: '保存规则',
+ allow: '允许',
+ spendRuleSectionTitle: '支出规则',
+ restrictionType: '限制类型',
+ restrictionTypeHelpAllow: '如果符合任一商户或类别,且不超过最大金额,则费用会被批准。',
+ restrictionTypeHelpBlock: '如果交易符合任一商户或类别,或超过最高金额,都会被拒付。',
+ addMerchant: '添加商家',
+ merchantContains: '商家包含',
+ merchantExactlyMatches: '商户完全匹配',
+ noBlockedMerchants: '没有被屏蔽的商户',
+ addMerchantToBlockSpend: '添加商家以阻止消费',
+ noAllowedMerchants: '没有允许的商家',
+ addMerchantToAllowSpend: '添加商户以允许消费',
+ matchType: '匹配类型',
+ matchTypeContains: '包含',
+ matchTypeExact: '完全匹配',
+ spendCategory: '支出类别',
+ maxAmount: '最高金额',
+ maxAmountHelp: '无论商家或消费类别限制如何,任何超过此金额的消费都会被拒绝。',
+ currencyMismatchTitle: '货币不匹配',
+ currencyMismatchPrompt: '若要设置最高金额,请选择以相同货币结算的卡片。',
+ reviewSelectedCards: '检查所选卡片',
+ summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary},还有 +${count} 个`,
+ confirmErrorApplyAtLeastOneSpendRuleToOneCard: '至少将一条支出规则应用到一张卡上',
+ confirmErrorCardRequired: '“卡”是必填字段',
+ confirmErrorApplyAtLeastOneSpendRule: '至少应用一条支出规则',
+ categories: '类别',
+ merchants: '商家',
+ max: '最大',
+ categoryOptions: {
+ [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: '航空公司',
+ [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: '酒精和酒吧',
+ [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon 和书店',
+ [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: '汽车',
+ [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: '租车',
+ [CONST.SPEND_RULES.CATEGORIES.DINING]: '餐饮',
+ [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: '燃料和汽油',
+ [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: '政府和非营利组织',
+ [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: '杂货',
+ [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: '健身房和健身',
+ [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: '医疗保健',
+ [CONST.SPEND_RULES.CATEGORIES.HOTELS]: '酒店',
+ [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: '网络和电话',
+ [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: '办公用品',
+ [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: '停车和过路费',
+ [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: '专业服务',
+ [CONST.SPEND_RULES.CATEGORIES.RETAIL]: '零售',
+ [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: '配送与交付',
+ [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: '软件',
+ [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: '公共交通和网约车',
+ [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: '旅行社',
+ },
},
},
planTypePage: {
diff --git a/src/libs/API/parameters/SetExpensifyCardRuleParams.ts b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts
new file mode 100644
index 000000000000..ab47148ef022
--- /dev/null
+++ b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts
@@ -0,0 +1,7 @@
+type SetExpensifyCardRuleParams = {
+ domainAccountID: number;
+ cardRuleID: string;
+ cardRuleValue: string;
+};
+
+export default SetExpensifyCardRuleParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 04208236c001..d42a14bfd550 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -353,6 +353,7 @@ export type {default as DisablePolicyBillableModeParams} from './DisablePolicyBi
export type {default as SetWorkspaceEReceiptsEnabled} from './SetWorkspaceEReceiptsEnabled';
export type {default as SetPolicyAttendeeTrackingEnabledParams} from './SetPolicyAttendeeTrackingEnabledParams';
export type {default as ConfigureExpensifyCardsForPolicyParams} from './ConfigureExpensifyCardsForPolicyParams';
+export type {default as SetExpensifyCardRuleParams} from './SetExpensifyCardRuleParams';
export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams';
export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams';
export type {default as AddDelegateParams} from './AddDelegateParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index fafe1c8bf369..c85c32bb34ea 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -469,6 +469,7 @@ const WRITE_COMMANDS = {
UPDATE_WORKSPACE_APPROVAL: 'UpdateWorkspaceApproval',
REMOVE_WORKSPACE_APPROVAL: 'RemoveWorkspaceApproval',
CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY: 'ConfigureExpensifyCardsForPolicy',
+ SET_EXPENSIFY_CARD_RULE: 'SetExpensifyCardRule',
CREATE_EXPENSIFY_CARD: 'CreateExpensifyCard',
CREATE_ADMIN_ISSUED_VIRTUAL_CARD: 'CreateAdminIssuedVirtualCard',
QUEUE_EXPENSIFY_CARD_FOR_BILLING: 'Domain_QueueExpensifyCardForBilling',
@@ -1077,6 +1078,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL]: Parameters.UpdateWorkspaceApprovalParams;
[WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL]: Parameters.RemoveWorkspaceApprovalParams;
[WRITE_COMMANDS.CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY]: Parameters.ConfigureExpensifyCardsForPolicyParams;
+ [WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE]: Parameters.SetExpensifyCardRuleParams;
[WRITE_COMMANDS.CREATE_EXPENSIFY_CARD]: Omit;
[WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD]: Omit;
[WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING]: Parameters.QueueExpensifyCardForBillingParams;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 9b5ed39286a1..7d493b401ff1 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1525,6 +1525,36 @@ function getCardCurrency(card?: OnyxEntry, cardSettings?: OnyxEntry): string | undefined {
+ if (!cardIDs?.length) {
+ return undefined;
+ }
+
+ const cardsRecord = cardsList ?? {};
+ const currencies = new Set();
+
+ for (const cardID of cardIDs) {
+ const card = cardsRecord[cardID];
+ if (!card) {
+ continue;
+ }
+
+ const currency = card.nameValuePairs?.currency;
+ if (currency) {
+ currencies.add(currency);
+ }
+ }
+
+ if (currencies.size !== 1) {
+ return undefined;
+ }
+
+ return Array.from(currencies).at(0);
+}
+
function getCardHintText(validFrom: string | undefined, validThru: string | undefined, assigneeTimeZone: SelectedTimezone | undefined, translate: LocalizedTranslate) {
if (!validFrom || !validThru) {
return;
@@ -1676,6 +1706,7 @@ export {
getDisplayableExpensifyCards,
isExpiredCard,
getCardCurrency,
+ getSelectedCardsSharedCurrency,
getCardHintText,
resolveTransactionCardFields,
};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 851b5b30685c..3a98255cd6b9 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -891,6 +891,12 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default,
[SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default,
[SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default,
+ [SCREENS.WORKSPACE.RULES_SPEND_NEW]: () => require('../../../../pages/workspace/rules/SpendRules/AddSpendRulePage').default,
+ [SCREENS.WORKSPACE.RULES_SPEND_CARD]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleCardPage').default,
+ [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleCategoryPage').default,
+ [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage').default,
+ [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMerchantsPage').default,
+ [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage').default,
[SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default,
[SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMatchTypePage').default,
[SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantPage').default,
diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
index 03d7f0957f7a..fdd824010562 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
@@ -294,6 +294,12 @@ const WORKSPACE_TO_RHP: Partial['config'] = {
[SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: {
path: ROUTES.RULES_MERCHANT_NEW.route,
},
+ [SCREENS.WORKSPACE.RULES_SPEND_NEW]: {
+ path: ROUTES.RULES_SPEND_NEW.route,
+ },
+ [SCREENS.WORKSPACE.RULES_SPEND_CARD]: {
+ path: ROUTES.RULES_SPEND_CARD.route,
+ },
+ [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: {
+ path: ROUTES.RULES_SPEND_CATEGORY.route,
+ },
+ [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: {
+ path: ROUTES.RULES_SPEND_MAX_AMOUNT.route,
+ },
+ [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: {
+ path: ROUTES.RULES_SPEND_MERCHANTS.route,
+ },
+ [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: {
+ path: ROUTES.RULES_SPEND_MERCHANT_EDIT.route,
+ },
[SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: {
path: ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.route,
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 27648bd3ca95..515d4d5c5e3e 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1440,6 +1440,25 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.RULES_SPEND_NEW]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.RULES_SPEND_CARD]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: {
+ policyID: string;
+ merchantIndex: string;
+ };
[SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: {
policyID: string;
ruleID: string;
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index 41ed86a7e876..e6716b99bb84 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -12,6 +12,7 @@ import type {
RequestReplacementExpensifyCardParams,
ResolveFraudAlertParams,
RevealExpensifyCardDetailsParams,
+ SetExpensifyCardRuleParams,
SetPersonalCardReimbursableParams,
StartIssueNewCardFlowParams,
UnassignCardParams,
@@ -29,8 +30,11 @@ import Log from '@libs/Log';
import {isReportOpenOrUnsubmitted} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {SpendRuleForm} from '@src/types/form';
+import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm';
import type {Card, CompanyCardFeedWithDomainID, Report, Transaction} from '@src/types/onyx';
import type {CardLimitType, ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card';
+import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings';
import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails';
import type {ConnectionName} from '@src/types/onyx/Policy';
import type {SavedCSVColumnLayoutData} from '@src/types/onyx/SavedCSVColumnLayout';
@@ -1555,6 +1559,196 @@ function queueExpensifyCardForBilling(feedCountry: string, domainAccountID: numb
API.write(WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING, parameters);
}
+function isSpendRuleASTNode(value: unknown): value is ExpensifyCardRuleFilter {
+ return !!value && typeof value === 'object' && 'left' in value && 'operator' in value && 'right' in value;
+}
+
+function combineSpendRuleASTNodes(nodes: ExpensifyCardRuleFilter[], operator: ValueOf): ExpensifyCardRuleFilter | undefined {
+ const [firstNode, ...remainingNodes] = nodes;
+ if (!firstNode) {
+ return undefined;
+ }
+
+ return remainingNodes.reduce((accumulator, node) => ({left: accumulator, operator, right: node}), firstNode);
+}
+
+function buildSpendRuleAST(spendRuleValues: SpendRuleForm): ExpensifyCardRule | undefined {
+ const cardIDs = spendRuleValues.cardIDs ?? [];
+ if (cardIDs.length === 0) {
+ return undefined;
+ }
+
+ const merchantNames = (spendRuleValues.merchantNames ?? []).map((merchant) => merchant.trim()).filter((merchant) => merchant !== '');
+ const merchantMatchTypes = spendRuleValues.merchantMatchTypes ?? [];
+ const categories = (spendRuleValues.categories ?? []).map((category) => category.trim()).filter((category) => category !== '');
+ const maxAmount = spendRuleValues.maxAmount?.trim() ?? '';
+
+ const cardNode: ExpensifyCardRuleFilter = {
+ left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
+ operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO,
+ right: cardIDs,
+ };
+
+ const exactMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO);
+ const containsMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) !== CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO);
+ const merchantNodes: ExpensifyCardRuleFilter[] = [];
+
+ if (exactMerchantNames.length > 0) {
+ merchantNodes.push({
+ left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT,
+ operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO,
+ right: exactMerchantNames,
+ });
+ }
+
+ if (containsMerchantNames.length > 0) {
+ merchantNodes.push({
+ left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT,
+ operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS,
+ right: containsMerchantNames,
+ });
+ }
+
+ const merchantNode = combineSpendRuleASTNodes(merchantNodes, CONST.SEARCH.SYNTAX_OPERATORS.OR);
+ const categoryNode =
+ categories.length > 0
+ ? {
+ left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
+ operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO,
+ right: categories,
+ }
+ : undefined;
+
+ const criteriaNode = combineSpendRuleASTNodes([merchantNode, categoryNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.OR);
+ const amountNode =
+ maxAmount !== ''
+ ? {
+ left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT,
+ operator:
+ spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK
+ ? CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN
+ : CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO,
+ right: [maxAmount],
+ }
+ : undefined;
+
+ const ruleNode = combineSpendRuleASTNodes(
+ [amountNode, criteriaNode].filter(Boolean) as ExpensifyCardRuleFilter[],
+ spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? CONST.SEARCH.SYNTAX_OPERATORS.OR : CONST.SEARCH.SYNTAX_OPERATORS.AND,
+ );
+ const filters = combineSpendRuleASTNodes([cardNode, ruleNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.AND);
+
+ if (!filters) {
+ return undefined;
+ }
+
+ return {
+ created: DateUtils.getDBTime(),
+ action: spendRuleValues.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW,
+ filters,
+ };
+}
+
+function getSpendRuleFormValuesFromCardRule(cardRule: ExpensifyCardRule): SpendRuleForm | undefined {
+ if (!cardRule || typeof cardRule !== 'object' || !('filters' in cardRule) || !('action' in cardRule)) {
+ return undefined;
+ }
+
+ if (!isSpendRuleASTNode(cardRule.filters)) {
+ return undefined;
+ }
+
+ const formValues: SpendRuleForm = {
+ cardIDs: [],
+ restrictionAction: cardRule.action,
+ merchantNames: [],
+ merchantMatchTypes: [],
+ categories: [],
+ maxAmount: '',
+ };
+
+ const traverseFilters = (filterNode: ExpensifyCardRuleFilter) => {
+ const {left, operator, right} = filterNode;
+
+ if (isSpendRuleASTNode(left)) {
+ traverseFilters(left);
+ }
+
+ if (isSpendRuleASTNode(right)) {
+ traverseFilters(right);
+ return;
+ }
+
+ if (typeof left !== 'string' || !Array.isArray(right)) {
+ return;
+ }
+
+ if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
+ formValues.cardIDs = right;
+ return;
+ }
+
+ if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ formValues.maxAmount = typeof right === 'string' ? right : (right.at(0) ?? '');
+ return;
+ }
+
+ if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) {
+ formValues.categories = right.filter(isSpendRuleCategory);
+ return;
+ }
+
+ if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) {
+ formValues.merchantNames = [...formValues.merchantNames, ...right];
+ formValues.merchantMatchTypes = [...formValues.merchantMatchTypes, ...right.map(() => operator)];
+ }
+ };
+
+ traverseFilters(cardRule.filters);
+
+ return formValues;
+}
+
+function setExpensifyCardRule(domainAccountID: number, cardRuleID: string, spendRuleValues: SpendRuleForm) {
+ const ruleID = cardRuleID;
+ const ruleAST = buildSpendRuleAST(spendRuleValues);
+ if (!ruleAST) {
+ return;
+ }
+
+ const optimisticData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`,
+ value: {
+ cardRules: {
+ [ruleID]: ruleAST,
+ },
+ },
+ },
+ ];
+
+ const failureData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`,
+ value: {
+ cardRules: {
+ [ruleID]: null,
+ },
+ },
+ },
+ ];
+
+ const parameters: SetExpensifyCardRuleParams = {
+ domainAccountID,
+ cardRuleID: ruleID,
+ cardRuleValue: JSON.stringify(ruleAST),
+ };
+
+ API.write(WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE, parameters, {optimisticData, failureData});
+}
+
/**
* Resolves a fraud alert for a given card.
* When the user clicks on the whisper it sets the optimistic data to the resolution and calls the API
@@ -1656,5 +1850,7 @@ export {
clearIssueNewCardFormData,
setDraftInviteAccountID,
resolveFraudAlert,
+ setExpensifyCardRule,
+ getSpendRuleFormValuesFromCardRule,
};
export type {ReplacementReason};
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index e161fa357241..0b1bec9825fb 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -48,7 +48,7 @@ import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {ExpenseRuleForm, MerchantRuleForm} from '@src/types/form';
+import type {ExpenseRuleForm, MerchantRuleForm, SpendRuleForm} from '@src/types/form';
import type {AppReview, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, Policy, ReportAttributesDerivedValue} from '@src/types/onyx';
import type Login from '@src/types/onyx/Login';
import type {Errors} from '@src/types/onyx/OnyxCommon';
@@ -1877,6 +1877,18 @@ function clearDraftMerchantRule() {
Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, null);
}
+function setDraftSpendRule(ruleData: Partial) {
+ Onyx.set(ONYXKEYS.FORMS.SPEND_RULE_FORM, ruleData);
+}
+
+function updateDraftSpendRule(ruleData: Partial) {
+ Onyx.merge(ONYXKEYS.FORMS.SPEND_RULE_FORM, ruleData);
+}
+
+function clearDraftSpendRule() {
+ Onyx.set(ONYXKEYS.FORMS.SPEND_RULE_FORM, null);
+}
+
export {
closeAccount,
setServerErrorsOnForm,
@@ -1928,6 +1940,9 @@ export {
setDraftMerchantRule,
updateDraftMerchantRule,
clearDraftMerchantRule,
+ setDraftSpendRule,
+ updateDraftSpendRule,
+ clearDraftSpendRule,
openTroubleshootSettingsPage,
openMultifactorAuthenticationRevokePage,
};
diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx
index 4a0741f8e8b5..1685065dc958 100644
--- a/src/pages/workspace/rules/PolicyRulesPage.tsx
+++ b/src/pages/workspace/rules/PolicyRulesPage.tsx
@@ -56,7 +56,7 @@ function PolicyRulesPage({route}: PolicyRulesPageProps) {
- {!!policy?.areExpensifyCardsEnabled && }
+ {!!policy?.areExpensifyCardsEnabled && }
diff --git a/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx
new file mode 100644
index 000000000000..faa25375a5ff
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import type SCREENS from '@src/SCREENS';
+import SpendRulePageBase from './SpendRulePageBase';
+
+type AddSpendRulePageProps = PlatformStackScreenProps;
+
+function AddSpendRulePage({route}: AddSpendRulePageProps) {
+ return (
+
+ );
+}
+
+AddSpendRulePage.displayName = 'AddSpendRulePage';
+
+export default AddSpendRulePage;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx
new file mode 100644
index 000000000000..225c4f9269cb
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx
@@ -0,0 +1,252 @@
+import {useFocusEffect} from '@react-navigation/native';
+import React, {useCallback, useEffect, useState} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import BlockingView from '@components/BlockingViews/BlockingView';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import CardListItem from '@components/SelectionList/ListItem/CardListItem';
+import type {AdditionalCardProps} from '@components/SelectionList/ListItem/CardListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons';
+import useDefaultFundID from '@hooks/useDefaultFundID';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+import useSearchResults from '@hooks/useSearchResults';
+import useThemeIllustrations from '@hooks/useThemeIllustrations';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getSpendRuleFormValuesFromCardRule} from '@libs/actions/Card';
+import {updateDraftSpendRule} from '@libs/actions/User';
+import {filterCardsByPersonalDetails, filterInactiveCards, getCardFeedIcon, sortCardsByCardholderName} from '@libs/CardUtils';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import {getHeaderMessage} from '@libs/OptionsListUtils';
+import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import variables from '@styles/variables';
+import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {Card, ExpensifyCardSettings, WorkspaceCardsList} from '@src/types/onyx';
+import type {ExpensifyCardRule} from '@src/types/onyx/ExpensifyCardSettings';
+
+type ExpensifyCardListItem = ListItem &
+ AdditionalCardProps & {
+ card: Card;
+ };
+
+type SpendRuleCardPageProps = PlatformStackScreenProps;
+
+function getCardIDsWithSpendRules(cardRules: Record | undefined): Set {
+ const cardIDs = new Set();
+ if (!cardRules) {
+ return cardIDs;
+ }
+
+ for (const rule of Object.values(cardRules)) {
+ const formValues = getSpendRuleFormValuesFromCardRule(rule);
+ for (const cardID of formValues?.cardIDs ?? []) {
+ const numericCardID = Number(cardID);
+ if (Number.isFinite(numericCardID)) {
+ cardIDs.add(numericCardID);
+ }
+ }
+ }
+
+ return cardIDs;
+}
+
+function getEligibleCards(cardsList: OnyxEntry, expensifyCardSettings: ExpensifyCardSettings) {
+ const {cardList, ...cards} = cardsList ?? {};
+ const cardIDsWithSpendRules = getCardIDsWithSpendRules(expensifyCardSettings?.cardRules);
+ return Object.values(cards).filter((card: Card) => !cardIDsWithSpendRules.has(card.cardID));
+}
+
+function SpendRuleCardPage({route}: SpendRuleCardPageProps) {
+ const {policyID} = route.params;
+ const styles = useThemeStyles();
+ const {translate, localeCompare} = useLocalize();
+ const defaultFundID = useDefaultFundID(policyID);
+ const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
+ const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`);
+ const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM);
+ const illustrations = useMemoizedLazyIllustrations(['Telescope']);
+ const themeIllustrations = useThemeIllustrations();
+ const companyCardFeedIcons = useCompanyCardFeedIcons();
+
+ const [selectedCardIDs, setSelectedCardIDs] = useState([]);
+
+ useFocusEffect(
+ useCallback(() => {
+ setSelectedCardIDs(spendRuleForm?.cardIDs ?? []);
+ }, [spendRuleForm?.cardIDs]),
+ );
+
+ const {isOffline} = useNetwork({
+ onReconnect: () => {
+ openPolicyExpensifyCardsPage(policyID, defaultFundID);
+ },
+ });
+
+ const isCardSettingsLoading = !isOffline && (!expensifyCardSettings || expensifyCardSettings.isLoading) && !expensifyCardSettings?.hasOnceLoaded;
+ const eligibleCards = expensifyCardSettings ? getEligibleCards(cardsList, expensifyCardSettings) : [];
+
+ const filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails);
+ const sortCards = (cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare);
+
+ const [inputValue, setInputValue, filteredCards] = useSearchResults(eligibleCards, filterCard, sortCards);
+
+ const listData: ExpensifyCardListItem[] = filteredCards.map((card) => {
+ const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID;
+ const cardOwnerPersonalDetails = personalDetails?.[accountID] ?? undefined;
+ const cardName = card.nameValuePairs?.cardTitle;
+ const displayName = getDisplayNameOrDefault(cardOwnerPersonalDetails, '', false);
+ return {
+ keyForList: String(card.cardID),
+ text: displayName !== '' ? displayName : (cardName ?? ''),
+ accountID,
+ card,
+ lastFourPAN: card.lastFourPAN,
+ isVirtual: !!card.nameValuePairs?.isVirtual,
+ shouldShowOwnersAvatar: true,
+ cardOwnerPersonalDetails,
+ bankIcon: {
+ icon: getCardFeedIcon(card.bank, themeIllustrations, companyCardFeedIcons),
+ },
+ };
+ });
+
+ useEffect(() => {
+ openPolicyExpensifyCardsPage(policyID, defaultFundID);
+ }, [defaultFundID, policyID]);
+
+ const toggleCard = (item: ExpensifyCardListItem) => {
+ setSelectedCardIDs((prev) => {
+ if (prev.includes(item.keyForList)) {
+ return prev.filter((id) => id !== item.keyForList);
+ }
+ return [...prev, item.keyForList];
+ });
+ };
+
+ const toggleSelectAll = () => {
+ const visibleKeys = listData.map((item) => item.keyForList);
+ const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedCardIDs.includes(key));
+ if (allVisibleSelected) {
+ const visibleSet = new Set(visibleKeys);
+ setSelectedCardIDs((prev) => prev.filter((id) => !visibleSet.has(id)));
+ return;
+ }
+ setSelectedCardIDs((prev) => {
+ const next = new Set(prev);
+ for (const key of visibleKeys) {
+ next.add(key);
+ }
+ return Array.from(next);
+ });
+ };
+
+ const handleSave = () => {
+ if (isCardSettingsLoading) {
+ return;
+ }
+
+ const eligibleCardIDs = new Set(eligibleCards.map((card) => String(card.cardID)));
+ const validSelectedCardIDs = selectedCardIDs.filter((cardID) => eligibleCardIDs.has(cardID));
+
+ if (validSelectedCardIDs.length !== selectedCardIDs.length) {
+ setSelectedCardIDs(validSelectedCardIDs);
+ return;
+ }
+
+ updateDraftSpendRule({cardIDs: validSelectedCardIDs});
+ Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID));
+ };
+
+ const headerMessage = getHeaderMessage(listData.length > 0, false, inputValue, countryCode, false);
+
+ return (
+
+ {isCardSettingsLoading ? (
+
+ ) : (
+
+ Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))}
+ />
+ 0 ? toggleSelectAll : undefined}
+ onCheckboxPress={toggleCard}
+ onSelectRow={toggleCard}
+ selectedItems={selectedCardIDs}
+ ListItem={CardListItem}
+ shouldUseDefaultRightHandSideCheckmark={false}
+ shouldUpdateFocusedIndex
+ shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
+ listEmptyContent={
+
+ }
+ footerContent={
+
+ }
+ />
+
+ )}
+
+ );
+}
+
+SpendRuleCardPage.displayName = 'SpendRuleCardPage';
+
+export default SpendRuleCardPage;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx
new file mode 100644
index 000000000000..154b246b0086
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx
@@ -0,0 +1,150 @@
+import {useFocusEffect} from '@react-navigation/native';
+import React, {useCallback, useState} from 'react';
+import BlockingView from '@components/BlockingViews/BlockingView';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useSearchResults from '@hooks/useSearchResults';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {updateDraftSpendRule} from '@libs/actions/User';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import variables from '@styles/variables';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import {SPEND_RULE_CATEGORIES} from '@src/types/form/SpendRuleForm';
+import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm';
+
+type CategoryListItem = ListItem & {
+ value: SpendRuleCategory;
+};
+
+type SpendRuleCategoryPageProps = PlatformStackScreenProps;
+
+function SpendRuleCategoryPage({route}: SpendRuleCategoryPageProps) {
+ const {policyID} = route.params;
+ const styles = useThemeStyles();
+ const {translate, localeCompare} = useLocalize();
+ const illustrations = useMemoizedLazyIllustrations(['Telescope']);
+
+ const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM);
+
+ const [selectedCategories, setSelectedCategories] = useState([]);
+
+ useFocusEffect(
+ useCallback(() => {
+ setSelectedCategories(spendRuleForm?.categories ?? []);
+ }, [spendRuleForm?.categories]),
+ );
+
+ const categoryItems: CategoryListItem[] = SPEND_RULE_CATEGORIES.map((category) => ({
+ keyForList: category,
+ text: translate(`workspace.rules.spendRules.categoryOptions.${category}`),
+ value: category,
+ }));
+
+ const filterCategory = (item: CategoryListItem, searchInput: string) => (item.text ?? '').toLowerCase().includes(searchInput.toLowerCase());
+ const sortCategories = (items: CategoryListItem[]) => items.sort((a, b) => localeCompare(a.text ?? '', b.text ?? ''));
+
+ const [inputValue, setInputValue, filteredCategoryItems] = useSearchResults(categoryItems, filterCategory, sortCategories);
+
+ const listData: CategoryListItem[] = filteredCategoryItems.map((item) => ({
+ ...item,
+ isSelected: selectedCategories.includes(item.value),
+ }));
+
+ const toggleCategory = (item: CategoryListItem) => {
+ setSelectedCategories((prev) => {
+ if (prev.includes(item.value)) {
+ return prev.filter((categoryName) => categoryName !== item.value);
+ }
+ return [...prev, item.value];
+ });
+ };
+
+ const toggleSelectAll = () => {
+ const visibleValues = listData.map((item) => item.value);
+ const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedCategories.includes(value));
+ if (allVisibleSelected) {
+ const visibleSet = new Set(visibleValues);
+ setSelectedCategories((prev) => prev.filter((value) => !visibleSet.has(value)));
+ return;
+ }
+ setSelectedCategories((prev) => {
+ const next = new Set(prev);
+ for (const value of visibleValues) {
+ next.add(value);
+ }
+ return Array.from(next);
+ });
+ };
+
+ const handleSave = () => {
+ updateDraftSpendRule({categories: selectedCategories});
+ Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID));
+ };
+
+ return (
+
+ Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))}
+ />
+ 0 ? toggleSelectAll : undefined}
+ onCheckboxPress={toggleCategory}
+ onSelectRow={toggleCategory}
+ selectedItems={selectedCategories}
+ ListItem={MultiSelectListItem}
+ shouldUpdateFocusedIndex
+ shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
+ listEmptyContent={
+
+ }
+ footerContent={
+
+ }
+ />
+
+ );
+}
+
+SpendRuleCategoryPage.displayName = 'SpendRuleCategoryPage';
+
+export default SpendRuleCategoryPage;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx
new file mode 100644
index 000000000000..c0233369058e
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import {View} from 'react-native';
+import AmountForm from '@components/AmountForm';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useDefaultFundID from '@hooks/useDefaultFundID';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {updateDraftSpendRule} from '@libs/actions/User';
+import {filterInactiveCards, getSelectedCardsSharedCurrency} from '@libs/CardUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/SpendRuleForm';
+
+type SpendRuleMaxAmountPageProps = PlatformStackScreenProps;
+
+function SpendRuleMaxAmountPage({route}: SpendRuleMaxAmountPageProps) {
+ const {policyID} = route.params;
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
+ const domainAccountID = useDefaultFundID(policyID);
+
+ const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM);
+ const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
+
+ const selectedCurrency = getSelectedCardsSharedCurrency(spendRuleForm?.cardIDs, cardsList) ?? CONST.CURRENCY.USD;
+ const defaultValue = spendRuleForm?.maxAmount ?? '';
+
+ return (
+
+
+ Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))}
+ />
+ {
+ updateDraftSpendRule({maxAmount: maxAmount.trim()});
+ Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID));
+ }}
+ submitButtonText={translate('common.save')}
+ enabledWhenOffline
+ shouldHideFixErrorsAlert
+ addBottomSafeAreaPadding
+ >
+
+
+ {translate('workspace.rules.spendRules.maxAmountHelp')}
+
+
+
+
+ );
+}
+
+SpendRuleMaxAmountPage.displayName = 'SpendRuleMaxAmountPage';
+
+export default SpendRuleMaxAmountPage;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx
new file mode 100644
index 000000000000..31dfb1cf7b93
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx
@@ -0,0 +1,151 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem';
+import type {ListItem} from '@components/SelectionList/ListItem/types';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {updateDraftSpendRule} from '@libs/actions/User';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/SpendRuleForm';
+
+type SpendRuleMerchantEditPageProps = PlatformStackScreenProps;
+
+type MatchTypeItem = ListItem & {
+ value: ValueOf;
+};
+
+function SpendRuleMerchantEditPage({route}: SpendRuleMerchantEditPageProps) {
+ const {policyID, merchantIndex} = route.params;
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {inputCallbackRef} = useAutoFocusInput();
+ const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM);
+
+ const merchantNames = spendRuleForm?.merchantNames ?? [];
+ const merchantMatchTypes = spendRuleForm?.merchantMatchTypes ?? [];
+ const isNew = merchantIndex === ROUTES.NEW;
+ const index = isNew ? -1 : Number(merchantIndex);
+ const existingMerchantName = isNew ? undefined : merchantNames.at(index);
+ const existingMerchantMatchType = isNew ? undefined : merchantMatchTypes.at(index);
+
+ const [merchantName, setMerchantName] = useState(existingMerchantName ?? '');
+ const [matchType, setMatchType] = useState>(existingMerchantMatchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS);
+
+ const goBack = () => {
+ Navigation.goBack(ROUTES.RULES_SPEND_MERCHANTS.getRoute(policyID));
+ };
+
+ const submit = () => {
+ const trimmedMerchantName = merchantName.trim();
+ if (!trimmedMerchantName) {
+ if (!isNew) {
+ const updatedMerchantNames = merchantNames.filter((_, merchantArrayIndex) => merchantArrayIndex !== index);
+ const updatedMerchantMatchTypes = merchantMatchTypes.filter((_, merchantArrayIndex) => merchantArrayIndex !== index);
+ updateDraftSpendRule({merchantNames: updatedMerchantNames, merchantMatchTypes: updatedMerchantMatchTypes});
+ }
+ goBack();
+ return;
+ }
+
+ const updatedMerchantNames = isNew
+ ? [...merchantNames, trimmedMerchantName]
+ : merchantNames.map((name, merchantArrayIndex) => (merchantArrayIndex === index ? trimmedMerchantName : name));
+
+ const updatedMerchantMatchTypes = isNew
+ ? [...merchantMatchTypes, matchType]
+ : merchantMatchTypes.map((type, merchantArrayIndex) => (merchantArrayIndex === index ? matchType : type));
+ updateDraftSpendRule({merchantNames: updatedMerchantNames, merchantMatchTypes: updatedMerchantMatchTypes});
+ goBack();
+ };
+
+ const matchTypeItems: MatchTypeItem[] = [
+ {
+ value: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS,
+ keyForList: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS,
+ text: translate('workspace.rules.merchantRules.matchTypeContains'),
+ isSelected: matchType === CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS,
+ },
+ {
+ value: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO,
+ keyForList: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO,
+ text: translate('workspace.rules.merchantRules.matchTypeExact'),
+ isSelected: matchType === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO,
+ },
+ ];
+
+ const onSelectMatchType = (item: MatchTypeItem) => {
+ setMatchType(item.value);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {translate('workspace.rules.spendRules.matchType')}
+
+
+
+
+
+ );
+}
+
+SpendRuleMerchantEditPage.displayName = 'SpendRuleMerchantEditPage';
+
+export default SpendRuleMerchantEditPage;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx
new file mode 100644
index 000000000000..dd5e12de10e0
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import BlockingView from '@components/BlockingViews/BlockingView';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItem from '@components/MenuItem';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type SpendRuleMerchantsPageProps = PlatformStackScreenProps;
+
+function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPageProps) {
+ const {policyID} = route.params;
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM);
+ const illustrations = useMemoizedLazyIllustrations(['FoodTruck']);
+ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']);
+
+ const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW;
+ const merchantNames = spendRuleForm?.merchantNames ?? [];
+ const merchantMatchTypes = spendRuleForm?.merchantMatchTypes ?? [];
+
+ const emptyStateTitle =
+ restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? translate('workspace.rules.spendRules.noBlockedMerchants') : translate('workspace.rules.spendRules.noAllowedMerchants');
+
+ const emptyStateSubtitle =
+ restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK
+ ? translate('workspace.rules.spendRules.addMerchantToBlockSpend')
+ : translate('workspace.rules.spendRules.addMerchantToAllowSpend');
+
+ const goBack = () => Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID));
+
+ const addMerchant = () => {
+ Navigation.navigate(ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, ROUTES.NEW));
+ };
+
+ return (
+
+
+
+
+
+ {merchantNames.length > 0 ? (
+ merchantNames.map((merchantName, index) => (
+ Navigation.navigate(ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, String(index)))}
+ shouldShowRightIcon
+ title={merchantName}
+ titleStyle={styles.flex1}
+ sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
+ />
+ ))
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+SpendRuleMerchantsPage.displayName = 'SpendRuleMerchantsPage';
+
+export default SpendRuleMerchantsPage;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx
new file mode 100644
index 000000000000..cede12a24247
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx
@@ -0,0 +1,233 @@
+import React, {useEffect, useState} from 'react';
+import {View} from 'react-native';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import {ModalActions} from '@components/Modal/Global/ModalContext';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useConfirmModal from '@hooks/useConfirmModal';
+import useDefaultFundID from '@hooks/useDefaultFundID';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setExpensifyCardRule} from '@libs/actions/Card';
+import {clearDraftSpendRule, updateDraftSpendRule} from '@libs/actions/User';
+import {filterInactiveCards, getCardDescriptionForSearchTable, getSelectedCardsSharedCurrency} from '@libs/CardUtils';
+import {convertToBackendAmount, convertToDisplayString} from '@libs/CurrencyUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {rand64} from '@libs/NumberUtils';
+import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm';
+import SpendRuleRestrictionTypeToggle from './SpendRuleRestrictionTypeToggle';
+import getTruncatedSpendRuleSummary from './SpendRuleSummaryUtils';
+
+type SpendRulePageBaseProps = {
+ policyID: string;
+ titleKey: TranslationPaths;
+ testID: string;
+};
+
+function getErrorMessage(hasSelectedCards: boolean, hasAnyRuleApplied: boolean, translate: (path: TranslationPaths) => string) {
+ if (!hasSelectedCards && !hasAnyRuleApplied) {
+ return translate('workspace.rules.spendRules.confirmErrorCardRequired');
+ }
+ if (!hasSelectedCards) {
+ return translate('workspace.rules.spendRules.confirmErrorApplyAtLeastOneSpendRuleToOneCard');
+ }
+ if (!hasAnyRuleApplied) {
+ return translate('workspace.rules.spendRules.confirmErrorApplyAtLeastOneSpendRule');
+ }
+ return '';
+}
+
+function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {showConfirmModal} = useConfirmModal();
+ const domainAccountID = useDefaultFundID(policyID);
+ const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
+ const [isErrorVisible, setIsErrorVisible] = useState(false);
+
+ useEffect(() => () => clearDraftSpendRule(), []);
+
+ const cardIDs = spendRuleForm?.cardIDs;
+ const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW;
+ const merchantNames = spendRuleForm?.merchantNames ?? [];
+ const categories = spendRuleForm?.categories ?? [];
+ const maxAmount = spendRuleForm?.maxAmount ?? '';
+
+ const clearError = () => {
+ setIsErrorVisible(false);
+ };
+
+ const selectedCurrency = getSelectedCardsSharedCurrency(cardIDs, cardsList);
+ const parsedMaxAmount = Number.parseFloat(maxAmount);
+ const maxAmountMenuTitle = Number.isFinite(parsedMaxAmount) ? convertToDisplayString(convertToBackendAmount(parsedMaxAmount), selectedCurrency ?? CONST.CURRENCY.USD) : '';
+
+ const openCurrencyMismatchModal = async () => {
+ const result = await showConfirmModal({
+ title: translate('workspace.rules.spendRules.currencyMismatchTitle'),
+ prompt: translate('workspace.rules.spendRules.currencyMismatchPrompt'),
+ confirmText: translate('workspace.rules.spendRules.reviewSelectedCards'),
+ cancelText: translate('common.cancel'),
+ });
+ if (result.action !== ModalActions.CONFIRM) {
+ return;
+ }
+ Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID));
+ };
+
+ function getCardsMenuTitle(cardIDsToSummarize: string[] | undefined): string {
+ return getTruncatedSpendRuleSummary(
+ cardIDsToSummarize?.map((id) => {
+ const card = cardsList?.[id];
+ if (card === undefined) {
+ return id;
+ }
+ const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID;
+ const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false);
+ return getCardDescriptionForSearchTable(card, displayName || undefined) || id;
+ }),
+ (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}),
+ );
+ }
+
+ function getMerchantMenuTitle(merchantNamesToSummarize: string[] | undefined): string {
+ return getTruncatedSpendRuleSummary(merchantNamesToSummarize, (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}));
+ }
+
+ function getCategoryMenuTitle(categoriesToSummarize: SpendRuleCategory[] | undefined): string {
+ return getTruncatedSpendRuleSummary(
+ categoriesToSummarize?.map((category) => translate(`workspace.rules.spendRules.categoryOptions.${category}`)),
+ (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}),
+ );
+ }
+
+ const cardsMenuTitle = getCardsMenuTitle(cardIDs);
+ const categoriesMenuTitle = getCategoryMenuTitle(categories);
+
+ const hasSelectedCards = !!cardIDs?.length;
+ const hasAnyMerchant = merchantNames.some((name) => name.trim() !== '');
+ const hasAnyCategory = categories.length > 0;
+ const hasMaxAmount = maxAmount.trim() !== '';
+ const hasAnyRuleApplied = hasAnyMerchant || hasAnyCategory || hasMaxAmount;
+ const errorMessage = getErrorMessage(hasSelectedCards, hasAnyRuleApplied, translate);
+
+ const handleSaveRule = () => {
+ if (errorMessage) {
+ setIsErrorVisible(true);
+ return;
+ }
+
+ if (!cardIDs) {
+ return;
+ }
+
+ clearError();
+ setExpensifyCardRule(domainAccountID, rand64(), spendRuleForm);
+ clearDraftSpendRule();
+ Navigation.goBack();
+ };
+
+ return (
+
+
+
+
+ {translate('workspace.rules.spendRules.cardsSectionTitle')}
+ {
+ clearError();
+ Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID));
+ }}
+ shouldShowRightIcon
+ title={cardsMenuTitle}
+ numberOfLinesTitle={2}
+ titleStyle={styles.flex1}
+ sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
+ />
+ {translate('workspace.rules.spendRules.spendRuleSectionTitle')}
+
+ {
+ clearError();
+ updateDraftSpendRule({restrictionAction: action});
+ }}
+ />
+
+ {
+ clearError();
+ Navigation.navigate(ROUTES.RULES_SPEND_MERCHANTS.getRoute(policyID));
+ }}
+ shouldShowRightIcon
+ title={getMerchantMenuTitle(spendRuleForm?.merchantNames)}
+ numberOfLinesTitle={2}
+ titleStyle={styles.flex1}
+ sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
+ />
+ {
+ clearError();
+ Navigation.navigate(ROUTES.RULES_SPEND_CATEGORY.getRoute(policyID));
+ }}
+ shouldShowRightIcon
+ title={categoriesMenuTitle}
+ numberOfLinesTitle={2}
+ titleStyle={styles.flex1}
+ sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
+ />
+ {
+ clearError();
+ if (!selectedCurrency) {
+ openCurrencyMismatchModal();
+ return;
+ }
+ Navigation.navigate(ROUTES.RULES_SPEND_MAX_AMOUNT.getRoute(policyID));
+ }}
+ shouldShowRightIcon
+ title={maxAmountMenuTitle}
+ titleStyle={styles.flex1}
+ sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
+ />
+
+
+
+
+ );
+}
+
+SpendRulePageBase.displayName = 'SpendRulePageBase';
+
+export default SpendRulePageBase;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx
new file mode 100644
index 000000000000..ee9bb865bbcd
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import Button from '@components/Button';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+
+type SpendRuleRestrictionTypeToggleProps = {
+ restrictionAction: ValueOf;
+ onSelect: (action: ValueOf) => void;
+};
+
+function SpendRuleRestrictionTypeToggle({restrictionAction, onSelect}: SpendRuleRestrictionTypeToggleProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const isAllowSelected = restrictionAction === CONST.SPEND_RULES.ACTION.ALLOW;
+ const isBlockSelected = restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK;
+
+ const restrictionTypeHelperText = isAllowSelected ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') : translate('workspace.rules.spendRules.restrictionTypeHelpBlock');
+
+ return (
+ <>
+
+ {translate('workspace.rules.spendRules.restrictionType')}
+
+
+
+ {restrictionTypeHelperText}
+ >
+ );
+}
+
+SpendRuleRestrictionTypeToggle.displayName = 'SpendRuleRestrictionTypeToggle';
+
+export default SpendRuleRestrictionTypeToggle;
+export type {SpendRuleRestrictionTypeToggleProps};
diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleSummaryUtils.ts b/src/pages/workspace/rules/SpendRules/SpendRuleSummaryUtils.ts
new file mode 100644
index 000000000000..ff009fa60322
--- /dev/null
+++ b/src/pages/workspace/rules/SpendRules/SpendRuleSummaryUtils.ts
@@ -0,0 +1,28 @@
+const MAX_SUMMARY_CHARS = 74;
+
+type MoreCountFormatter = (summary: string, count: number) => string;
+
+function getTruncatedSpendRuleSummary(values: string[] | undefined, formatMoreCount: MoreCountFormatter): string {
+ const normalizedValues = (values ?? []).map((value) => value.trim()).filter((value) => value !== '');
+
+ if (!normalizedValues.length) {
+ return '';
+ }
+
+ let text = '';
+ let shownCount = 0;
+
+ for (const value of normalizedValues) {
+ const nextText = text ? `${text}, ${value}` : value;
+ if (nextText.length > MAX_SUMMARY_CHARS) {
+ continue;
+ }
+ text = nextText;
+ shownCount++;
+ }
+
+ const hiddenCount = Math.max(normalizedValues.length - shownCount, 0);
+ return text && hiddenCount > 0 ? formatMoreCount(text, hiddenCount) : text;
+}
+
+export default getTruncatedSpendRuleSummary;
diff --git a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx
index 500835d3f1b1..4054c923082d 100644
--- a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx
+++ b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx
@@ -1,29 +1,99 @@
-import React from 'react';
+import React, {useEffect} from 'react';
import {View} from 'react-native';
+import ActivityIndicator from '@components/ActivityIndicator';
import Badge from '@components/Badge';
import Icon from '@components/Icon';
import MenuItem from '@components/MenuItem';
import Section from '@components/Section';
import Text from '@components/Text';
import useConfirmModal from '@hooks/useConfirmModal';
+import useDefaultFundID from '@hooks/useDefaultFundID';
+import useEnvironment from '@hooks/useEnvironment';
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {getSpendRuleFormValuesFromCardRule} from '@libs/actions/Card';
+import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy';
+import {filterInactiveCards, getCardDescriptionForSearchTable, getSelectedCardsSharedCurrency, isCard} from '@libs/CardUtils';
+import {convertToBackendAmount, convertToDisplayString} from '@libs/CurrencyUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {SpendRuleForm} from '@src/types/form';
+import getTruncatedSpendRuleSummary from './SpendRuleSummaryUtils';
-function SpendRulesSection() {
- const {translate} = useLocalize();
+type SpendRulesSectionProps = {
+ policyID: string;
+};
+
+type SpendRuleSummaryPart = {
+ badgeLabel: string;
+ text: string;
+ isNeutral?: boolean;
+};
+
+function getSpendRuleSummaryParts(
+ formValues: SpendRuleForm,
+ selectedCurrency: string | undefined,
+ actionLabel: string,
+ translate: ReturnType['translate'],
+): SpendRuleSummaryPart[] {
+ const summaryParts: SpendRuleSummaryPart[] = [];
+ const merchantNames = getTruncatedSpendRuleSummary(formValues.merchantNames, (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}));
+ const categories = getTruncatedSpendRuleSummary(
+ formValues.categories.map((category) => translate(`workspace.rules.spendRules.categoryOptions.${category}`)),
+ (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}),
+ );
+ const maxAmount = formValues.maxAmount.trim();
+
+ if (merchantNames) {
+ summaryParts.push({badgeLabel: actionLabel, text: `${translate('workspace.rules.spendRules.merchants')}: ${merchantNames}`});
+ }
+
+ if (categories) {
+ summaryParts.push({badgeLabel: actionLabel, text: `${translate('workspace.rules.spendRules.categories')}: ${categories}`});
+ }
+
+ if (maxAmount) {
+ summaryParts.push({
+ badgeLabel: translate('workspace.rules.spendRules.max'),
+ text: `${translate('iou.amount')}: ${convertToDisplayString(convertToBackendAmount(Number.parseFloat(maxAmount)), selectedCurrency ?? CONST.CURRENCY.USD)}`,
+ isNeutral: true,
+ });
+ }
+
+ return summaryParts;
+}
+
+function SpendRulesSection({policyID}: SpendRulesSectionProps) {
+ const {translate, localeCompare} = useLocalize();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const theme = useTheme();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']);
+ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock', 'Plus']);
const {showConfirmModal} = useConfirmModal();
const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardProtectionIllustration']);
+ const {isProduction} = useEnvironment();
+ const {isOffline} = useNetwork();
+ const defaultFundID = useDefaultFundID(policyID);
+ const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`);
+ const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+
+ useEffect(() => {
+ openPolicyExpensifyCardsPage(policyID, defaultFundID);
+ }, [policyID, defaultFundID]);
+
+ const isCardSettingsLoading = !isOffline && (!expensifyCardSettings || expensifyCardSettings.isLoading) && !expensifyCardSettings?.hasOnceLoaded;
const showBuiltInProtectionModal = () => {
showConfirmModal({
@@ -45,6 +115,40 @@ function SpendRulesSection() {
const defaultRuleTitle = translate('workspace.rules.spendRules.defaultRuleTitle');
const descriptionLabel = translate('workspace.rules.spendRules.defaultRuleDescription');
const blockLabel = translate('workspace.rules.spendRules.block');
+ const allowLabel = translate('workspace.rules.spendRules.allow');
+ const createdRules = Object.entries(expensifyCardSettings?.cardRules ?? {})
+ .map(([ruleID, cardRule]) => {
+ const formValues = getSpendRuleFormValuesFromCardRule(cardRule);
+ if (!formValues) {
+ return undefined;
+ }
+ const actionLabel = formValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? blockLabel : allowLabel;
+ const selectedCurrency = getSelectedCardsSharedCurrency(formValues.cardIDs, cardsList);
+ const cardSummary = getTruncatedSpendRuleSummary(
+ formValues.cardIDs.map((cardID) => {
+ const card = cardsList?.[cardID];
+ if (!card || !isCard(card)) {
+ return cardID;
+ }
+
+ const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID;
+ const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false);
+ return getCardDescriptionForSearchTable(card, displayName || undefined) || cardID;
+ }),
+ (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}),
+ );
+
+ return {
+ ruleID,
+ actionLabel,
+ cardSummary,
+ summaryParts: getSpendRuleSummaryParts(formValues, selectedCurrency, actionLabel, translate),
+ isBlock: formValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK,
+ created: cardRule.created,
+ };
+ })
+ .filter((rule) => rule !== undefined)
+ .sort((a, b) => localeCompare(a.created, b.created));
const renderSectionTitle = () => (
@@ -62,7 +166,7 @@ function SpendRulesSection() {
@@ -106,6 +210,70 @@ function SpendRulesSection() {
onPress={showBuiltInProtectionModal}
shouldShowRightIcon
/>
+ {isCardSettingsLoading ? (
+
+
+
+ ) : (
+ createdRules.map((rule) => (
+
+ }
+ accessibilityLabel={`${rule.summaryParts.map((part) => `${part.badgeLabel}. ${part.text}`).join('. ')}. ${rule.cardSummary}`}
+ sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_ITEM}
+ interactive={false}
+ />
+ ))
+ )}
+ {!isProduction && (
+