Skip to content

Commit

Permalink
Merge pull request #47409 from software-mansion-labs/rules/individual…
Browse files Browse the repository at this point in the history
…-expense-rules

[OldDot Rules Migration] Individual expense rules
  • Loading branch information
marcaaron authored Aug 26, 2024
2 parents c7ed5e5 + 3750539 commit cc9b8b7
Show file tree
Hide file tree
Showing 34 changed files with 1,203 additions and 56 deletions.
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const CONST = {
DEFAULT_ONYX_DUMP_FILE_NAME: 'onyx-state.txt',
DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL],
DISABLED_MAX_EXPENSE_VALUE: 10000000000,
POLICY_BILLABLE_MODES: {
BILLABLE: 'billable',
NON_BILLABLE: 'nonBillable',
},

// Note: Group and Self-DM excluded as these are not tied to a Workspace
WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT],
Expand Down Expand Up @@ -594,6 +598,7 @@ const CONST = {
CONCIERGE_ICON_URL: `${CLOUDFRONT_URL}/images/icons/concierge_2022.png`,
UPWORK_URL: 'https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22',
DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works',
DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/',
GITHUB_URL: 'https://github.com/Expensify/App',
TERMS_URL: `${USE_EXPENSIFY_URL}/terms`,
PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`,
Expand Down
9 changes: 9 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,12 @@ const ONYXKEYS = {
SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft',
TEXT_PICKER_MODAL_FORM: 'textPickerModalForm',
TEXT_PICKER_MODAL_FORM_DRAFT: 'textPickerModalFormDraft',
RULES_REQUIRED_RECEIPT_AMOUNT_FORM: 'rulesRequiredReceiptAmountForm',
RULES_REQUIRED_RECEIPT_AMOUNT_FORM_DRAFT: 'rulesRequiredReceiptAmountFormDraft',
RULES_MAX_EXPENSE_AMOUNT_FORM: 'rulesMaxExpenseAmountForm',
RULES_MAX_EXPENSE_AMOUNT_FORM_DRAFT: 'rulesMaxExpenseAmountFormDraft',
RULES_MAX_EXPENSE_AGE_FORM: 'rulesMaxExpenseAgeForm',
RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft',
},
} as const;

Expand Down Expand Up @@ -703,6 +709,9 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.SAGE_INTACCT_DIMENSION_TYPE_FORM]: FormTypes.SageIntacctDimensionForm;
[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM]: FormTypes.SearchAdvancedFiltersForm;
[ONYXKEYS.FORMS.TEXT_PICKER_MODAL_FORM]: FormTypes.TextPickerModalForm;
[ONYXKEYS.FORMS.RULES_REQUIRED_RECEIPT_AMOUNT_FORM]: FormTypes.RulesRequiredReceiptAmountForm;
[ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm;
[ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm;
};

type OnyxFormDraftValuesMapping = {
Expand Down
16 changes: 16 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,22 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit',
getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const,
},
RULES_RECEIPT_REQUIRED_AMOUNT: {
route: 'settings/workspaces/:policyID/rules/receipt-required-amount',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/receipt-required-amount` as const,
},
RULES_MAX_EXPENSE_AMOUNT: {
route: 'settings/workspaces/:policyID/rules/max-expense-amount',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/max-expense-amount` as const,
},
RULES_MAX_EXPENSE_AGE: {
route: 'settings/workspaces/:policyID/rules/max-expense-age',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/max-expense-age` as const,
},
RULES_BILLABLE_DEFAULT: {
route: 'settings/workspaces/:policyID/rules/billable',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/billable` as const,
},
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
route: 'referral/:contentType',
Expand Down
4 changes: 4 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@ const SCREENS = {
DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit',
UPGRADE: 'Workspace_Upgrade',
RULES: 'Policy_Rules',
RULES_RECEIPT_REQUIRED_AMOUNT: 'Rules_Receipt_Required_Amount',
RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount',
RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age',
RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default',
},

EDIT_REQUEST: {
Expand Down
69 changes: 67 additions & 2 deletions src/components/AmountForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Browser from '@libs/Browser';
Expand All @@ -12,6 +12,7 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
import BigNumberPad from './BigNumberPad';
import FormHelpMessage from './FormHelpMessage';
import TextInput from './TextInput';
import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol';
Expand Down Expand Up @@ -41,6 +42,10 @@ type AmountFormProps = {

/** Custom max amount length. It defaults to CONST.IOU.AMOUNT_MAX_LENGTH */
amountMaxLength?: number;

label?: string;

displayAsTextInput?: boolean;
} & Pick<TextInputWithCurrencySymbolProps, 'hideCurrencySymbol' | 'extraSymbol'> &
Pick<BaseTextInputProps, 'autoFocus'>;

Expand All @@ -57,7 +62,19 @@ const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
const NUM_PAD_VIEW_ID = 'numPadView';

function AmountForm(
{value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, amountMaxLength, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true, ...rest}: AmountFormProps,
{
value: amount,
currency = CONST.CURRENCY.USD,
extraDecimals = 0,
amountMaxLength,
errorText,
onInputChange,
onCurrencyButtonPress,
displayAsTextInput = false,
isCurrencyPressable = true,
label,
...rest
}: AmountFormProps,
forwardedRef: ForwardedRef<BaseTextInputRef>,
) {
const styles = useThemeStyles();
Expand Down Expand Up @@ -124,6 +141,29 @@ function AmountForm(
[amountMaxLength, currentAmount, decimals, onInputChange, selection],
);

/**
* Set a new amount value properly formatted
*
* @param text - Changed text from user input
*/
const setFormattedAmount = (text: string) => {
// Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value
// More info: https://github.com/Expensify/App/issues/16974
const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(text);
const replacedCommasAmount = MoneyRequestUtils.replaceCommasWithPeriod(newAmountWithoutSpaces);
const withLeadingZero = MoneyRequestUtils.addLeadingZero(replacedCommasAmount);

if (!MoneyRequestUtils.validateAmount(withLeadingZero, decimals, amountMaxLength)) {
setSelection((prevSelection) => ({...prevSelection}));
return;
}

const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(withLeadingZero);
const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current;
setSelection(getNewSelection(selection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length));
onInputChange?.(strippedAmount);
};

// Modifies the amount to match the decimals for changed currency.
useEffect(() => {
// If the changed currency supports decimals, we can return
Expand Down Expand Up @@ -195,6 +235,31 @@ function AmountForm(
const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit);
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();

if (displayAsTextInput) {
return (
<TextInput
label={label}
value={formattedAmount}
onChangeText={setFormattedAmount}
ref={(ref: BaseTextInputRef) => {
if (typeof forwardedRef === 'function') {
forwardedRef(ref);
} else if (forwardedRef && 'current' in forwardedRef) {
// eslint-disable-next-line no-param-reassign
forwardedRef.current = ref;
}
textInput.current = ref;
}}
prefixCharacter={currency}
prefixStyle={styles.colorMuted}
keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD}
inputMode={CONST.INPUT_MODE.DECIMAL}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
);
}

return (
<>
<View
Expand Down
24 changes: 20 additions & 4 deletions src/components/TextInput/BaseTextInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,15 @@ function BaseTextInput(
shouldInterceptSwipe = false,
autoCorrect = true,
prefixCharacter = '',
suffixCharacter = '',
inputID,
isMarkdownEnabled = false,
excludedMarkdownStyles = [],
shouldShowClearButton = false,
prefixContainerStyle = [],
prefixStyle = [],
suffixContainerStyle = [],
suffixStyle = [],
contentWidth,
...inputProps
}: BaseTextInputProps,
Expand All @@ -84,7 +87,7 @@ function BaseTextInput(
// Disabling this line for saftiness as nullish coalescing works only if value is undefined or null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const initialValue = value || defaultValue || '';
const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter;
const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter;

const [isFocused, setIsFocused] = useState(false);
const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry);
Expand Down Expand Up @@ -143,13 +146,13 @@ function BaseTextInput(
const deactivateLabel = useCallback(() => {
const newValue = value ?? '';

if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter) {
if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) {
return;
}

animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE);
isLabelActive.current = false;
}, [animateLabel, forceActiveLabel, prefixCharacter, value]);
}, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]);

const onFocus = (event: NativeSyntheticEvent<TextInputFocusEventData>) => {
inputProps.onFocus?.(event);
Expand Down Expand Up @@ -246,7 +249,7 @@ function BaseTextInput(
// Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const inputHelpText = errorText || hint;
const newPlaceholder = !!prefixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined;
const newPlaceholder = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined;
const newTextInputContainerStyles: StyleProp<ViewStyle> = StyleSheet.flatten([
styles.textInputContainer,
textInputContainerStyles,
Expand Down Expand Up @@ -277,6 +280,7 @@ function BaseTextInput(
}, [inputStyle]);

const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft);
const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight);

return (
<>
Expand Down Expand Up @@ -367,6 +371,7 @@ function BaseTextInput(
inputStyle,
(!hasLabel || isMultiline) && styles.pv0,
inputPaddingLeft,
inputPaddingRight,
inputProps.secureTextEntry && styles.secureInput,

// Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear
Expand Down Expand Up @@ -403,6 +408,17 @@ function BaseTextInput(
defaultValue={defaultValue}
markdownStyle={markdownStyle}
/>
{!!suffixCharacter && (
<View style={[styles.textInputSuffixWrapper, suffixContainerStyle]}>
<Text
tabIndex={-1}
style={[styles.textInputSuffix, !hasLabel && styles.pv0, styles.pointerEventsNone, suffixStyle]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
{suffixCharacter}
</Text>
</View>
)}
{isFocused && !isReadOnly && shouldShowClearButton && !!value && <TextInputClearButton onPressButton={() => setValue('')} />}
{inputProps.isLoading && (
<ActivityIndicator
Expand Down
9 changes: 9 additions & 0 deletions src/components/TextInput/BaseTextInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ type CustomBaseTextInputProps = {
/** Prefix character */
prefixCharacter?: string;

/** Suffix character */
suffixCharacter?: string;

/** Whether autoCorrect functionality should enable */
autoCorrect?: boolean;

Expand Down Expand Up @@ -125,6 +128,12 @@ type CustomBaseTextInputProps = {
/** Style for the prefix container */
prefixContainerStyle?: StyleProp<ViewStyle>;

/** Style for the suffix */
suffixStyle?: StyleProp<TextStyle>;

/** Style for the suffix container */
suffixContainerStyle?: StyleProp<ViewStyle>;

/** The width of inner content */
contentWidth?: number;
};
Expand Down
18 changes: 18 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export default {
filterLogs: 'Filter Logs',
network: 'Network',
reportID: 'Report ID',
days: 'days',
},
location: {
useCurrent: 'Use current location',
Expand Down Expand Up @@ -3615,6 +3616,23 @@ export default {
individualExpenseRules: {
title: 'Expenses',
subtitle: 'Set spend controls and defaults for individual expenses. You can also create rules for',
receiptRequiredAmount: 'Receipt required amount',
receiptRequiredAmountDescription: 'Require receipts when spend exceeds this amount, unless overridden by a category rule.',
maxExpenseAmount: 'Max expense amount',
maxExpenseAmountDescription: 'Flag spend that exceeds this amount, unless overridden by a category rule.',
maxAge: 'Max age',
maxExpenseAge: 'Max expense age',
maxExpenseAgeDescription: 'Flag spend older than a specific number of days.',
maxExpenseAgeDays: (age: number) => `${age} ${Str.pluralize('day', 'days', age)}`,
billableDefault: 'Billable default',
billableDefaultDescription: 'Choose whether cash and credit card expenses should be billable by default. Billable expenses are enabled or disabled in',
billable: 'Billable',
billableDescription: 'Expenses are most often re-billed to clients',
nonBillable: 'Non-billable',
nonBillableDescription: 'Expenses are occasionally re-billed to clients',
eReceipts: 'eReceipts',
eReceiptsHint: 'eReceipts are auto-created',
eReceiptsHintLink: 'for most USD credit transactions',
},
expenseReportRules: {
title: 'Expense reports',
Expand Down
18 changes: 18 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ export default {
filterLogs: 'Registros de filtrado',
network: 'La red',
reportID: 'ID del informe',
days: 'días',
},
connectionComplete: {
title: 'Conexión completa',
Expand Down Expand Up @@ -3666,6 +3667,23 @@ export default {
individualExpenseRules: {
title: 'Gastos',
subtitle: 'Establece controles y valores predeterminados para gastos individuales. También puedes crear reglas para',
receiptRequiredAmount: 'Cantidad requerida para los recibos',
receiptRequiredAmountDescription: 'Exige recibos cuando los gastos superen este importe, a menos que lo anule una regla de categoría.',
maxExpenseAmount: 'Importe máximo del gasto',
maxExpenseAmountDescription: 'Marca los gastos que superen este importe, a menos que una regla de categoría lo anule.',
maxAge: 'Antigüedad máxima',
maxExpenseAge: 'Antigüedad máxima de los gastos',
maxExpenseAgeDescription: 'Marca los gastos de más de un número determinado de días.',
maxExpenseAgeDays: (age: number) => `${age} ${Str.pluralize('día', 'días', age)}`,
billableDefault: 'Valor predeterminado facturable',
billableDefaultDescription: 'Elige si los gastos en efectivo y con tarjeta de crédito deben ser facturables por defecto. Los gastos facturables se activan o desactivan en',
billable: 'Facturable',
billableDescription: 'Los gastos se vuelven a facturar a los clientes en la mayoría de los casos',
nonBillable: 'No facturable',
nonBillableDescription: 'Los gastos se vuelven a facturar a los clientes en ocasiones',
eReceipts: 'Recibos electrónicos',
eReceiptsHint: 'Los recibos electrónicos se crean automáticamente',
eReceiptsHintLink: 'para la mayoría de las transacciones en USD',
},
expenseReportRules: {
title: 'Informes de gastos',
Expand Down
12 changes: 12 additions & 0 deletions src/libs/API/parameters/SetPolicyBillableMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
type SetPolicyBillableMode = {
defaultBillable: boolean;
/**
* Stringified JSON object with type of following structure:
* disabledFields: {
* defaultBillable: boolean;
* };
*/
disabledFields: string;
};

export default SetPolicyBillableMode;
6 changes: 6 additions & 0 deletions src/libs/API/parameters/SetPolicyExpenseMaxAge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type SetPolicyExpenseMaxAge = {
policyID: string;
maxExpenseAge: number;
};

export default SetPolicyExpenseMaxAge;
6 changes: 6 additions & 0 deletions src/libs/API/parameters/SetPolicyExpenseMaxAmount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type SetPolicyExpenseMaxAmount = {
policyID: string;
maxExpenseAmount: number;
};

export default SetPolicyExpenseMaxAmount;
6 changes: 6 additions & 0 deletions src/libs/API/parameters/SetPolicyExpenseMaxAmountNoReceipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type SetPolicyExpenseMaxAmountNoReceipt = {
policyID: string;
maxExpenseAmountNoReceipt: number;
};

export default SetPolicyExpenseMaxAmountNoReceipt;
5 changes: 5 additions & 0 deletions src/libs/API/parameters/SetWorkspaceEReceiptsEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type SetWorkspaceEReceiptsEnabled = {
eReceipts: boolean;
};

export default SetWorkspaceEReceiptsEnabled;
Loading

0 comments on commit cc9b8b7

Please sign in to comment.