diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2ad24e285cf9..1b602e1cec0f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2881,6 +2881,12 @@ const CONST = { REIMBURSEMENT_NO: 'reimburseNo', // None REIMBURSEMENT_MANUAL: 'reimburseManual', // Indirect }, + CASH_EXPENSE_REIMBURSEMENT_CHOICES: { + REIMBURSABLE_DEFAULT: 'reimbursableDefault', // Reimbursable by default + NON_REIMBURSABLE_DEFAULT: 'nonReimbursableDefault', // Non-reimbursable by default + ALWAYS_REIMBURSABLE: 'alwaysReimbursable', // Always Reimbursable + ALWAYS_NON_REIMBURSABLE: 'alwaysNonReimbursable', // Always Non Reimbursable + }, ID_FAKE: '_FAKE_', EMPTY: 'EMPTY', SECONDARY_ACTIONS: { @@ -3747,6 +3753,7 @@ const CONST = { TAG: 'tag', TAX_RATE: 'taxRate', TAX_AMOUNT: 'taxAmount', + REIMBURSABLE: 'reimbursable', REPORT: 'report', }, FOOTER: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 91acda37cdfe..7af7c1496b2c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1933,6 +1933,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/billable', getRoute: (policyID: string) => `workspaces/${policyID}/rules/billable` as const, }, + RULES_REIMBURSABLE_DEFAULT: { + route: 'workspaces/:policyID/rules/reimbursable', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/reimbursable` as const, + }, RULES_PROHIBITED_DEFAULT: { route: 'workspaces/:policyID/rules/prohibited', getRoute: (policyID: string) => `workspaces/${policyID}/rules/prohibited` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1df64bce3aa1..fc4b60cbbb35 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -642,6 +642,7 @@ const SCREENS = { RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', + RULES_REIMBURSABLE_DEFAULT: 'Rules_Reimbursable_Default', RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', PER_DIEM: 'Per_Diem', diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 40cad1f662bb..3698ee5f50e4 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -183,6 +183,12 @@ type MoneyRequestConfirmationListProps = { /** The PDF password callback */ onPDFPassword?: () => void; + /** Function to toggle reimbursable */ + onToggleReimbursable?: (isOn: boolean) => void; + + /** Flag indicating if the IOU is reimbursable */ + iouIsReimbursable?: boolean; + /** Show remove expense confirmation modal */ showRemoveExpenseConfirmModal?: () => void; }; @@ -225,6 +231,8 @@ function MoneyRequestConfirmationList({ isConfirming, onPDFLoadError, onPDFPassword, + iouIsReimbursable = true, + onToggleReimbursable, showRemoveExpenseConfirmModal, }: MoneyRequestConfirmationListProps) { const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); @@ -1154,6 +1162,8 @@ function MoneyRequestConfirmationList({ unit={unit} onPDFLoadError={onPDFLoadError} onPDFPassword={onPDFPassword} + iouIsReimbursable={iouIsReimbursable} + onToggleReimbursable={onToggleReimbursable} isReceiptEditable={isReceiptEditable} /> ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 6bcabfec7d9d..c45d8cd0a42f 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -31,6 +31,7 @@ import { getTaxAmount, getTaxName, isAmountMissing, + isCardTransaction, isCreatedMissing, isFetchingWaypointsFromServer, shouldShowAttendees as shouldShowAttendeesTransactionUtils, @@ -198,6 +199,12 @@ type MoneyRequestConfirmationListFooterProps = { /** The PDF password callback */ onPDFPassword?: () => void; + + /** Function to toggle reimbursable */ + onToggleReimbursable?: (isOn: boolean) => void; + + /** Flag indicating if the IOU is reimbursable */ + iouIsReimbursable: boolean; }; function MoneyRequestConfirmationListFooter({ @@ -247,6 +254,8 @@ function MoneyRequestConfirmationListFooter({ unit, onPDFLoadError, onPDFPassword, + iouIsReimbursable, + onToggleReimbursable, isReceiptEditable = false, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); @@ -313,6 +322,7 @@ function MoneyRequestConfirmationListFooter({ const canModifyTaxFields = !isReadOnly && !isDistanceRequest && !isPerDiemRequest; // A flag for showing the billable field const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; + const shouldShowReimbursable = isPolicyExpenseChat && policy?.disabledFields?.reimbursable === false && !isTypeInvoice; // Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code const taxAmount = getTaxAmount(transaction, false); const formattedTaxAmount = convertToDisplayString(taxAmount, iouCurrencyCode); @@ -643,6 +653,25 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: shouldShowAttendees, }, + { + item: ( + + onToggleReimbursable?.(isOn)} + isActive={iouIsReimbursable} + disabled={isReadOnly} + wrapperStyle={styles.flex1} + /> + + ), + shouldShow: shouldShowReimbursable, + isSupplementary: true, + }, { item: ( shouldShowAttendeesTransactionUtils(iouType, policy), [iouType, policy]); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest); @@ -323,6 +332,17 @@ function MoneyRequestView({ [transaction, report, policy, policyTagList, policyCategories], ); + const saveReimbursable = useCallback( + (newReimbursable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !report?.reportID) { + return; + } + updateMoneyRequestReimbursable(transaction.transactionID, report?.reportID, newReimbursable, policy, policyTagList, policyCategories); + }, + [transaction, report, policy, policyTagList, policyCategories], + ); + if (isCardTransaction) { if (transactionPostedDate) { dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; @@ -347,7 +367,7 @@ function MoneyRequestView({ amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; } else if (isApproved) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; - } else if (isSettled) { + } else if (shouldShowPaid) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.settledExpensify')}`; } } @@ -706,7 +726,7 @@ function MoneyRequestView({ )} + {shouldShowReimbursable && ( + + + {Str.UCFirst(translate('iou.reimbursable'))} + + + + )} {shouldShowBillable && ( - + {translate('common.billable')} {!!getErrorForField('billable') && ( @@ -909,7 +948,7 @@ function MoneyRequestView({ onToggle={saveBillable} disabled={!canEdit} /> - + )} {!!parentReportID && ( diff --git a/src/languages/de.ts b/src/languages/de.ts index ebda7505ac79..76d6b1340d4a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5546,6 +5546,17 @@ const translations = { one: '1 Tag', other: (count: number) => `${count} Tage`, }), + cashExpenseDefault: 'Bargeldausgabe standard', + cashExpenseDefaultDescription: + 'Wählen Sie, wie Bargeldausgaben erstellt werden sollen. Eine Ausgabe gilt als Bargeldausgabe, wenn sie keine importierte Firmenkartentransaktion ist. Dazu gehören manuell erstellte Ausgaben, Belege, Pauschalen, Kilometer- und Zeitaufwand.', + reimbursableDefault: 'Erstattungsfähig', + reimbursableDefaultDescription: 'Ausgaben werden meistens an Mitarbeiter zurückgezahlt', + nonReimbursableDefault: 'Nicht erstattungsfähig', + nonReimbursableDefaultDescription: 'Ausgaben werden gelegentlich an Mitarbeiter zurückgezahlt', + alwaysReimbursable: 'Immer erstattungsfähig', + alwaysReimbursableDescription: 'Ausgaben werden immer an Mitarbeiter zurückgezahlt', + alwaysNonReimbursable: 'Nie erstattungsfähig', + alwaysNonReimbursableDescription: 'Ausgaben werden nie an Mitarbeiter zurückgezahlt', billableDefault: 'Abrechnungsstandard', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Wählen Sie aus, ob Bar- und Kreditkartenausgaben standardmäßig abrechnungsfähig sein sollen. Abrechnungsfähige Ausgaben werden in Tags aktiviert oder deaktiviert.`, @@ -5837,6 +5848,7 @@ const translations = { }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `aktualisiert "Kosten an Kunden weiterberechnen" auf "${newValue}" (vorher "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `aktualisiert "Bargeldausgabe Standard" auf "${newValue}" (vorher "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `"Standardberichtstitel erzwingen" ${value ? 'on' : 'aus'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `hat den Namen dieses Arbeitsbereichs in "${newName}" geändert (vorher "${oldName}")`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/en.ts b/src/languages/en.ts index 53ee0ec45d93..cecd595ddce8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5522,6 +5522,17 @@ const translations = { one: '1 day', other: (count: number) => `${count} days`, }), + cashExpenseDefault: 'Cash expense default', + cashExpenseDefaultDescription: + 'Choose how cash expenses should be created. An expense is considered a cash expense if it is not an imported company card transaction. This includes manually created expenses, receipts, per diem, distance, and time expenses.', + reimbursableDefault: 'Reimbursable', + reimbursableDefaultDescription: 'Expenses are most often paid back to employees', + nonReimbursableDefault: 'Non-reimbursable', + nonReimbursableDefaultDescription: 'Expenses are occasionally paid back to employees', + alwaysReimbursable: 'Always reimbursable', + alwaysReimbursableDescription: 'Expenses are always paid back to employees', + alwaysNonReimbursable: 'Always non-reimbursable', + alwaysNonReimbursableDescription: 'Expenses are never paid back to employees', billableDefault: 'Billable default', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Choose whether cash and credit card expenses should be billable by default. Billable expenses are enabled or disabled in tags.`, @@ -5812,6 +5823,7 @@ const translations = { return `updated the monthly report submission date to "${newValue}" (previously "${oldValue}")`; }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `updated "Re-bill expenses to clients" to "${newValue}" (previously "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `updated "Cash expense default" to "${newValue}" (previously "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `turned "Enforce default report titles" ${value ? 'on' : 'off'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `updated the name of this workspace to "${newName}" (previously "${oldName}")`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/es.ts b/src/languages/es.ts index 748af2fb4c09..ccc2fbc001d0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5556,6 +5556,17 @@ const translations = { one: '1 día', other: (count: number) => `${count} días`, }), + cashExpenseDefault: 'Valor predeterminado para gastos en efectivo', + cashExpenseDefaultDescription: + 'Elige cómo deben crearse los gastos en efectivo. Un gasto se considera en efectivo si no es una transacción importada desde una tarjeta de empresa. Esto incluye gastos creados manualmente, recibos, viáticos y gastos de distancia y tiempo.', + reimbursableDefault: 'Reembolsable', + reimbursableDefaultDescription: 'Los gastos suelen ser reembolsados a los empleados', + nonReimbursableDefault: 'No reembolsable', + nonReimbursableDefaultDescription: 'Los gastos ocasionalmente son reembolsados a los empleados', + alwaysReimbursable: 'Siempre reembolsable', + alwaysReimbursableDescription: 'Los gastos siempre se reembolsados a los empleados', + alwaysNonReimbursable: 'Siempre no reembolsable', + alwaysNonReimbursableDescription: 'Los gastos nunca son reembolsados a los empleados', billableDefault: 'Valor predeterminado facturable', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `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 etiquetas.`, @@ -5825,6 +5836,8 @@ const translations = { `actualizó "Antigüedad máxima de gastos (días)" a "${newValue}" (previamente "${oldValue === 'false' ? CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE : oldValue}")`, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `actualizó "Volver a facturar gastos a clientes" a "${newValue}" (previamente "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `actualizó "Valor predeterminado para gastos en efectivo" a "${newValue}" (previamente "${oldValue}")`, updateMonthlyOffset: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => { if (!oldValue) { return `establecer la fecha de envío del informe mensual a "${newValue}"`; diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b3e58a9c21b1..4405b57e83f4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5558,6 +5558,17 @@ const translations = { one: '1 jour', other: (count: number) => `${count} jours`, }), + cashExpenseDefault: 'Dépense en espèces par défaut', + cashExpenseDefaultDescription: + 'Choisissez comment les dépenses en espèces doivent être créées. Une dépense est considérée comme en espèces si elle n’est pas une transaction par carte d’entreprise importée. Cela inclut les dépenses créées manuellement, les reçus, les indemnités journalières, les frais kilométriques et les frais de temps.', + reimbursableDefault: 'Remboursable', + reimbursableDefaultDescription: 'Les dépenses sont généralement remboursées aux employés', + nonReimbursableDefault: 'Non remboursable', + nonReimbursableDefaultDescription: 'Les dépenses sont parfois remboursées aux employés', + alwaysReimbursable: 'Toujours remboursable', + alwaysReimbursableDescription: 'Les dépenses sont toujours remboursées aux employés', + alwaysNonReimbursable: 'Jamais remboursable', + alwaysNonReimbursableDescription: 'Les dépenses ne sont jamais remboursées aux employés', billableDefault: 'Par défaut facturable', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Choisissez si les dépenses en espèces et par carte de crédit doivent être facturables par défaut. Les dépenses facturables sont activées ou désactivées dans les tags.`, @@ -5848,6 +5859,8 @@ const translations = { }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `mis à jour "Refacturer les dépenses aux clients" à "${newValue}" (précédemment "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `mis à jour "Dépense en espèces par défaut" en "${newValue}" (anciennement "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `"Appliquer les titres de rapport par défaut" ${value ? 'sur' : 'désactivé'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `a mis à jour le nom de cet espace de travail en "${newName}" (précédemment "${oldName}")`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/it.ts b/src/languages/it.ts index f4e0c9921ab1..3884b87b7bdc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5558,6 +5558,17 @@ const translations = { one: '1 giorno', other: (count: number) => `${count} giorni`, }), + cashExpenseDefault: 'Spesa in contanti predefinita', + cashExpenseDefaultDescription: + 'Scegli come devono essere create le spese in contanti. Una spesa è considerata in contanti se non è una transazione su carta aziendale importata. Ciò include spese create manualmente, ricevute, diarie, chilometraggi e spese di tempo.', + reimbursableDefault: 'Rimborsabile', + reimbursableDefaultDescription: 'Le spese sono solitamente rimborsate ai dipendenti', + nonReimbursableDefault: 'Non rimborsabile', + nonReimbursableDefaultDescription: 'Le spese sono occasionalmente rimborsate ai dipendenti', + alwaysReimbursable: 'Sempre rimborsabile', + alwaysReimbursableDescription: 'Le spese sono sempre rimborsate ai dipendenti', + alwaysNonReimbursable: 'Mai rimborsabile', + alwaysNonReimbursableDescription: 'Le spese non sono mai rimborsate ai dipendenti', billableDefault: 'Predefinito fatturabile', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Scegli se le spese in contanti e con carta di credito devono essere fatturabili per impostazione predefinita. Le spese fatturabili possono essere abilitate o disabilitate nei tag.`, @@ -5850,6 +5861,8 @@ const translations = { }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `aggiornato "Riaddebita le spese ai clienti" a "${newValue}" (precedentemente "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `aggiornato "Spesa in contanti predefinita" a "${newValue}" (precedentemente "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `trasformato "Imponi titoli di report predefiniti" ${value ? 'su' : 'spento'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `ha aggiornato il nome di questo spazio di lavoro in "${newName}" (precedentemente "${oldName}")`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index fb88cd275a8f..cf4a87715ed8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5527,6 +5527,17 @@ const translations = { one: '1日', other: (count: number) => `${count}日間`, }), + cashExpenseDefault: '現金経費のデフォルト', + cashExpenseDefaultDescription: + '現金経費をどのように作成するかを選択します。インポートされた会社カード取引でない場合、経費は現金経費とみなされます。これには手動で作成された経費、領収書、日当、距離、時間経費が含まれます。', + reimbursableDefault: '精算可能', + reimbursableDefaultDescription: '経費は通常、従業員に返金されます', + nonReimbursableDefault: '精算不可', + nonReimbursableDefaultDescription: '経費は時々従業員に返金されます', + alwaysReimbursable: '常に精算可能', + alwaysReimbursableDescription: '経費は常に従業員に返金されます', + alwaysNonReimbursable: '常に精算不可', + alwaysNonReimbursableDescription: '経費は従業員に返金されません', billableDefault: '請求可能なデフォルト', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `現金とクレジットカードの支出をデフォルトで請求可能にするかどうかを選択します。請求可能な支出はタグで有効または無効に設定されます。`, @@ -5813,6 +5824,7 @@ const translations = { return `月次報告書の提出日を「${newValue}」(以前は「${oldValue}」)に更新しました。`; }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `"クライアントへの経費再請求"を"${newValue}"に更新しました(以前は"${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `「現金経費のデフォルト」を"${newValue}"に更新しました (以前は"${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `"デフォルトのレポートタイトルを強制する" ${value ? 'on' : 'オフ'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `このワークスペースの名前を「${newName}」(以前は「${oldName}」)に更新しました。`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c72491392315..cc8fe454d7f2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5555,6 +5555,17 @@ const translations = { one: '1 dag', other: (count: number) => `${count} dagen`, }), + cashExpenseDefault: 'Contante uitgave standaard', + cashExpenseDefaultDescription: + 'Kies hoe contante uitgaven moeten worden aangemaakt. Een uitgave wordt als contant beschouwd als het geen geïmporteerde bedrijfspastransactie is. Dit omvat handmatig aangemaakte uitgaven, bonnetjes, dagvergoedingen, kilometer- en tijdsuitgaven.', + reimbursableDefault: 'Vergoedbaar', + reimbursableDefaultDescription: 'Uitgaven worden meestal terugbetaald aan medewerkers', + nonReimbursableDefault: 'Niet vergoedbaar', + nonReimbursableDefaultDescription: 'Uitgaven worden soms terugbetaald aan medewerkers', + alwaysReimbursable: 'Altijd vergoedbaar', + alwaysReimbursableDescription: 'Uitgaven worden altijd terugbetaald aan medewerkers', + alwaysNonReimbursable: 'Nooit vergoedbaar', + alwaysNonReimbursableDescription: 'Uitgaven worden nooit terugbetaald aan medewerkers', billableDefault: 'Factureerbaar standaardwaarde', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Kies of contante en creditcarduitgaven standaard factureerbaar moeten zijn. Factureerbare uitgaven worden in tags in- of uitgeschakeld.`, @@ -5844,6 +5855,8 @@ const translations = { }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `bijgewerkt "Onkosten doorberekenen aan klanten" naar "${newValue}" (voorheen "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `bijgewerkt "Contante uitgave standaard" naar "${newValue}" (voorheen "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `omgezet "Standaardrapporttitels afdwingen" ${value ? 'op' : 'uit'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `heeft de naam van deze werkruimte bijgewerkt naar "${newName}" (voorheen "${oldName}")`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9317c6856407..b702ca43a803 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5543,6 +5543,17 @@ const translations = { one: '1 dzień', other: (count: number) => `${count} dni`, }), + cashExpenseDefault: 'Domyślny wydatek gotówkowy', + cashExpenseDefaultDescription: + 'Wybierz, jak powinny być tworzone wydatki gotówkowe. Wydatek jest uznawany za gotówkowy, jeśli nie jest importowaną transakcją kartą firmową. Obejmuje to ręcznie tworzone wydatki, paragony, diety, odległości i czas pracy.', + reimbursableDefault: 'Zwracany', + reimbursableDefaultDescription: 'Wydatki są zazwyczaj zwracane pracownikom', + nonReimbursableDefault: 'Niezwracany', + nonReimbursableDefaultDescription: 'Wydatki są czasami zwracane pracownikom', + alwaysReimbursable: 'Zawsze zwracany', + alwaysReimbursableDescription: 'Wydatki są zawsze zwracane pracownikom', + alwaysNonReimbursable: 'Nigdy nie zwracany', + alwaysNonReimbursableDescription: 'Wydatki nigdy nie są zwracane pracownikom', billableDefault: 'Domyślne do rozliczenia', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Wybierz, czy wydatki gotówkowe i kartą kredytową powinny być domyślnie rozliczane. Rozliczane wydatki można włączyć lub wyłączyć w tagi.`, @@ -5831,6 +5842,8 @@ const translations = { }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `zaktualizowano "Ponowne obciążenie klientów kosztami" na "${newValue}" (wcześniej "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `zaktualizowano "Domyślny wydatek gotówkowy" na "${newValue}" (wcześniej "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `zmieniono "Wymuś domyślne tytuły raportów" ${value ? 'na' : 'wyłączony'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `zaktualizował nazwę tego miejsca pracy na "${newName}" (wcześniej "${oldName}")`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c67680318bb0..6c80359aa06d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5554,6 +5554,17 @@ const translations = { one: '1 dia', other: (count: number) => `${count} dias`, }), + cashExpenseDefault: 'Despesa em dinheiro padrão', + cashExpenseDefaultDescription: + 'Escolha como as despesas em dinheiro devem ser criadas. Uma despesa é considerada em dinheiro se não for uma transação de cartão corporativo importada. Isso inclui despesas criadas manualmente, recibos, diárias, distância e despesas de tempo.', + reimbursableDefault: 'Reembolsável', + reimbursableDefaultDescription: 'Despesas geralmente são reembolsadas aos funcionários', + nonReimbursableDefault: 'Não reembolsável', + nonReimbursableDefaultDescription: 'Despesas às vezes são reembolsadas aos funcionários', + alwaysReimbursable: 'Sempre reembolsável', + alwaysReimbursableDescription: 'Despesas são sempre reembolsadas aos funcionários', + alwaysNonReimbursable: 'Nunca reembolsável', + alwaysNonReimbursableDescription: 'Despesas nunca são reembolsadas aos funcionários', billableDefault: 'Padrão faturável', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `Escolha se as despesas em dinheiro e cartão de crédito devem ser faturáveis por padrão. As despesas faturáveis são ativadas ou desativadas nas tags.`, @@ -5843,6 +5854,8 @@ const translations = { }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `atualizou "Refaturar despesas para clientes" para "${newValue}" (anteriormente "${oldValue}")`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `atualizou "Despesa em dinheiro padrão" para "${newValue}" (anteriormente "${oldValue}")`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `transformado "Aplicar títulos padrão de relatórios" ${value ? 'em' : 'desligado'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `atualizou o nome deste espaço de trabalho para "${newName}" (anteriormente "${oldName}")`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 72cf43dc6000..716f9949d00d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5459,6 +5459,16 @@ const translations = { one: '1天', other: (count: number) => `${count}天`, }), + cashExpenseDefault: '现金支出默认值', + cashExpenseDefaultDescription: '选择如何创建现金支出。如果不是导入的公司卡交易,则视为现金支出。这包括手动创建的支出、收据、津贴、里程和工时支出。', + reimbursableDefault: '可报销', + reimbursableDefaultDescription: '支出通常会报销给员工', + nonReimbursableDefault: '不可报销', + nonReimbursableDefaultDescription: '支出偶尔会报销给员工', + alwaysReimbursable: '始终可报销', + alwaysReimbursableDescription: '支出始终会报销给员工', + alwaysNonReimbursable: '始终不可报销', + alwaysNonReimbursableDescription: '支出永远不会报销给员工', billableDefault: '默认计费', billableDefaultDescription: ({tagsPageLink}: BillableDefaultDescriptionParams) => `C选择现金和信用卡支出是否默认可计费。可计费支出可在标签中启用或禁用。`, @@ -5736,6 +5746,7 @@ const translations = { return `将月度报告提交日期更新为“${newValue}”(之前为“${oldValue}”)`; }, updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `已将“重新向客户计费费用”更新为“${newValue}”(之前为“${oldValue}”)`, + updateDefaultReimbursable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `已将“现金支出默认值”更新为“${newValue}”(之前为“${oldValue}”)`, updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `"强制执行默认报告标题" ${value ? 'on' : '关'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedWorkspaceNameActionParams) => `已将此工作区的名称更新为“${newName}”(之前为“${oldName}”)`, updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts index 73f08deb6c4f..738393903c36 100644 --- a/src/libs/API/parameters/CreateDistanceRequestParams.ts +++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts @@ -15,6 +15,7 @@ type CreateDistanceRequestParams = { taxCode?: string; taxAmount?: number; billable?: boolean; + reimbursable?: boolean; transactionThreadReportID?: string; createdReportActionIDForThread?: string; payerEmail?: string; diff --git a/src/libs/API/parameters/CreatePerDiemRequestParams.ts b/src/libs/API/parameters/CreatePerDiemRequestParams.ts index 549ae61c40f2..c75de9e50ca6 100644 --- a/src/libs/API/parameters/CreatePerDiemRequestParams.ts +++ b/src/libs/API/parameters/CreatePerDiemRequestParams.ts @@ -21,6 +21,7 @@ type CreatePerDiemRequestParams = { transactionThreadReportID?: string; createdReportActionIDForThread: string | undefined; billable?: boolean; + reimbursable?: boolean; attendees?: string; }; diff --git a/src/libs/API/parameters/RequestMoneyParams.ts b/src/libs/API/parameters/RequestMoneyParams.ts index d8d5560c6bae..c50664abcf82 100644 --- a/src/libs/API/parameters/RequestMoneyParams.ts +++ b/src/libs/API/parameters/RequestMoneyParams.ts @@ -27,7 +27,7 @@ type RequestMoneyParams = { receiptGpsPoints?: string; transactionThreadReportID?: string; createdReportActionIDForThread: string | undefined; - reimbursible?: boolean; + reimbursable?: boolean; description?: string; attendees?: string; isTestDrive?: boolean; diff --git a/src/libs/API/parameters/SetPolicyCashExpenseModeParams.ts b/src/libs/API/parameters/SetPolicyCashExpenseModeParams.ts new file mode 100644 index 000000000000..5ee542d5e97f --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCashExpenseModeParams.ts @@ -0,0 +1,13 @@ +type SetPolicyCashExpenseModeParams = { + policyID: string; + defaultReimbursable: boolean; + /** + * Stringified JSON object with type of following structure: + * disabledFields: { + * reimbursable: boolean; + * }; + */ + disabledFields: string; +}; + +export default SetPolicyCashExpenseModeParams; diff --git a/src/libs/API/parameters/SplitBillParams.ts b/src/libs/API/parameters/SplitBillParams.ts index 3fda11b9ca98..bc99bf176e2d 100644 --- a/src/libs/API/parameters/SplitBillParams.ts +++ b/src/libs/API/parameters/SplitBillParams.ts @@ -9,6 +9,7 @@ type SplitBillParams = { category: string; tag: string; billable: boolean; + reimbursable: boolean; transactionID: string; reportActionID: string; createdReportActionID?: string; diff --git a/src/libs/API/parameters/StartSplitBillParams.ts b/src/libs/API/parameters/StartSplitBillParams.ts index 10f1029a0fba..031d75aca525 100644 --- a/src/libs/API/parameters/StartSplitBillParams.ts +++ b/src/libs/API/parameters/StartSplitBillParams.ts @@ -13,6 +13,7 @@ type StartSplitBillParams = { isFromGroupDM: boolean; createdReportActionID?: string; billable: boolean; + reimbursable: boolean; chatType?: string; taxCode?: string; taxAmount?: number; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index de8154e2dacb..a8239e8501f3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -316,6 +316,7 @@ export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAm export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; export type {default as UpdateCustomRules} from './UpdateCustomRules'; export type {default as SetPolicyBillableModeParams} from './SetPolicyBillableModeParams'; +export type {default as SetPolicyCashExpenseModeParams} from './SetPolicyCashExpenseModeParams'; export type {default as DisablePolicyBillableModeParams} from './DisablePolicyBillableModeParams'; export type {default as SetWorkspaceEReceiptsEnabled} from './SetWorkspaceEReceiptsEnabled'; export type {default as SetPolicyAttendeeTrackingEnabledParams} from './SetPolicyAttendeeTrackingEnabledParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 14a0be6c160b..582b4b1c1d29 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -191,6 +191,7 @@ const WRITE_COMMANDS = { COMPLETE_SPLIT_BILL: 'CompleteSplitBill', UPDATE_MONEY_REQUEST_ATTENDEES: 'UpdateMoneyRequestAttendees', UPDATE_MONEY_REQUEST_DATE: 'UpdateMoneyRequestDate', + UPDATE_MONEY_REQUEST_REIMBURSABLE: 'UpdateMoneyRequestReimbursable', UPDATE_MONEY_REQUEST_BILLABLE: 'UpdateMoneyRequestBillable', UPDATE_MONEY_REQUEST_MERCHANT: 'UpdateMoneyRequestMerchant', UPDATE_MONEY_REQUEST_TAG: 'UpdateMoneyRequestTag', @@ -244,6 +245,7 @@ const WRITE_COMMANDS = { SET_POLICY_EXPENSE_MAX_AGE: ' SetPolicyExpenseMaxAge', UPDATE_CUSTOM_RULES: 'UpdateCustomRules', SET_POLICY_BILLABLE_MODE: ' SetPolicyBillableMode', + SET_POLICY_REIMBURSABLE_MODE: 'SetPolicyReimbursableMode', DISABLE_POLICY_BILLABLE_MODE: 'DisablePolicyBillableExpenses', SET_WORKSPACE_ERECEIPTS_ENABLED: 'SetWorkspaceEReceiptsEnabled', SET_POLICY_ATTENDEE_TRACKING_ENABLED: 'SetPolicyAttendeeTrackingEnabled', @@ -675,6 +677,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT]: Parameters.UpdateMoneyRequestParams; + [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_AMOUNT]: Parameters.UpdateMoneyRequestParams; @@ -774,6 +777,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE]: Parameters.SetPolicyExpenseMaxAge; [WRITE_COMMANDS.UPDATE_CUSTOM_RULES]: Parameters.UpdateCustomRules; [WRITE_COMMANDS.SET_POLICY_BILLABLE_MODE]: Parameters.SetPolicyBillableModeParams; + [WRITE_COMMANDS.SET_POLICY_REIMBURSABLE_MODE]: Parameters.SetPolicyCashExpenseModeParams; [WRITE_COMMANDS.DISABLE_POLICY_BILLABLE_MODE]: Parameters.DisablePolicyBillableModeParams; [WRITE_COMMANDS.SET_WORKSPACE_ERECEIPTS_ENABLED]: Parameters.SetWorkspaceEReceiptsEnabled; [WRITE_COMMANDS.SET_POLICY_ATTENDEE_TRACKING_ENABLED]: Parameters.SetPolicyAttendeeTrackingEnabledParams; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index a793474e815b..d8196f1a1451 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -36,13 +36,6 @@ Onyx.connect({ callback: (value) => (allReports = value), }); -/** - * Utility to get message based on boolean literal value. - */ -function getBooleanLiteralMessage(value: string | undefined, truthyMessage: string, falsyMessage: string): string { - return value === 'true' ? truthyMessage : falsyMessage; -} - /** * Builds the partial message fragment for a modified field on the expense. */ @@ -339,8 +332,8 @@ function getForReportAction({ const hasModifiedReimbursable = isReportActionOriginalMessageAnObject && 'oldReimbursable' in reportActionOriginalMessage && 'reimbursable' in reportActionOriginalMessage; if (hasModifiedReimbursable) { buildMessageFragmentForValue( - getBooleanLiteralMessage(reportActionOriginalMessage?.reimbursable, translateLocal('iou.reimbursable'), translateLocal('iou.nonReimbursable')), - getBooleanLiteralMessage(reportActionOriginalMessage?.oldReimbursable, translateLocal('iou.reimbursable'), translateLocal('iou.nonReimbursable')), + reportActionOriginalMessage?.reimbursable ?? '', + reportActionOriginalMessage?.oldReimbursable ?? '', translateLocal('iou.expense'), true, setFragments, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 183aa8724395..ff64eb62be38 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -654,6 +654,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default, [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesBillableDefaultPage').default, + [SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesReimbursableDefaultPage').default, [SCREENS.WORKSPACE.RULES_CUSTOM]: () => require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index b2049090661f..b649b2025e8b 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -256,6 +256,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { path: ROUTES.RULES_BILLABLE_DEFAULT.route, }, + [SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: { + path: ROUTES.RULES_REIMBURSABLE_DEFAULT.route, + }, [SCREENS.WORKSPACE.RULES_CUSTOM]: { path: ROUTES.RULES_CUSTOM.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3dde215563e2..1debd138e31e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1084,6 +1084,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: { policyID: string; }; diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 118deb8f5092..3197e80bcdb4 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -14,6 +14,7 @@ import {getLoginsByAccountIDs, getPersonalDetailsByIDs} from './PersonalDetailsU import {getApprovalWorkflow, getCorrectedAutoReportingFrequency, getReimburserAccountID} from './PolicyUtils'; import { getDisplayNameForParticipant, + getMoneyRequestSpendBreakdown, getNextApproverAccountID, getPersonalDetailsForAccountID, hasViolations as hasViolationsReportUtils, @@ -162,6 +163,7 @@ function buildNextStep( ((report.total !== 0 && report.total !== undefined) || (report.unheldTotal !== 0 && report.unheldTotal !== undefined) || (report.unheldNonReimbursableTotal !== 0 && report.unheldNonReimbursableTotal !== undefined)); + const {reimbursableSpend} = getMoneyRequestSpendBreakdown(report); const ownerDisplayName = ownerPersonalDetails?.displayName ?? ownerPersonalDetails?.login ?? getDisplayNameForParticipant({accountID: ownerAccountID}); const policyOwnerDisplayName = policyOwnerPersonalDetails?.displayName ?? policyOwnerPersonalDetails?.login ?? getDisplayNameForParticipant({accountID: policy.ownerAccountID}); @@ -366,7 +368,7 @@ function buildNextStep( // Generates an optimistic nextStep once a report has been submitted case CONST.REPORT.STATUS_NUM.SUBMITTED: { if (policy.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL) { - optimisticNextStep = nextStepPayExpense; + optimisticNextStep = reimbursableSpend > 0 ? noActionRequired : nextStepPayExpense; break; } // Another owner @@ -452,7 +454,8 @@ function buildNextStep( email: currentUserEmail, }, report, - ) + ) || + reimbursableSpend === 0 ) { optimisticNextStep = noActionRequired; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 8b38c5c1503c..03d27c3dc7fc 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2666,6 +2666,19 @@ function getPolicyChangeLogDefaultBillableMessage(action: ReportAction): string return getReportActionText(action); } +function getPolicyChangeLogDefaultReimbursableMessage(action: ReportAction): string { + const {oldDefaultReimbursable, newDefaultReimbursable} = getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof oldDefaultReimbursable === 'string' && typeof newDefaultReimbursable === 'string') { + return translateLocal('workspaceActions.updateDefaultReimbursable', { + oldValue: oldDefaultReimbursable, + newValue: newDefaultReimbursable, + }); + } + + return getReportActionText(action); +} + function getPolicyChangeLogDefaultTitleEnforcedMessage(action: ReportAction): string { const {value} = getOriginalMessage(action as ReportAction) ?? {}; @@ -3128,6 +3141,7 @@ export { isReopenedAction, isRetractedAction, getIntegrationSyncFailedMessage, + getPolicyChangeLogDefaultReimbursableMessage, getManagerOnVacation, getVacationer, getSubmittedTo, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index d4303ba041c1..d2158d08f488 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -15,6 +15,7 @@ import { isInstantSubmitEnabled, isPolicyMember, isPreferredExporter, + isSubmitAndClose, } from './PolicyUtils'; import {getIOUActionForReportID, getIOUActionForTransactionID, getOneTransactionThreadReportID, isPayAction} from './ReportActionsUtils'; import {getReportPrimaryAction, isPrimaryPayAction} from './ReportPrimaryActionUtils'; @@ -24,6 +25,7 @@ import { canHoldUnholdReportAction, getTransactionDetails, hasOnlyHeldExpenses, + hasOnlyNonReimbursableTransactions, hasReportBeenReopened as hasReportBeenReopenedUtils, hasReportBeenRetracted as hasReportBeenRetractedUtils, isArchivedReport, @@ -99,6 +101,10 @@ function isSplitAction(report: Report, reportTransactions: Transaction[], policy return false; } + if (hasOnlyNonReimbursableTransactions(report.reportID) && isSubmitAndClose(policy) && isInstantSubmitEnabled(policy)) { + return false; + } + const isSubmitter = isCurrentUserSubmitter(report); const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const isManager = (report.managerID ?? CONST.DEFAULT_NUMBER_ID) === getCurrentUserAccountID(); @@ -547,6 +553,10 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], return true; } + if (hasOnlyNonReimbursableTransactions(parentReport.reportID) && isSubmitAndClose(policy) && isInstantSubmitEnabled(policy)) { + return false; + } + const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 526ee5d67896..cf5e84dee695 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -156,6 +156,7 @@ import { getOneTransactionThreadReportID, getOriginalMessage, getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getRenamedAction, @@ -693,6 +694,7 @@ type TransactionDetails = { customUnitRateID?: string; comment: string; category: string; + reimbursable: boolean; billable: boolean; tag: string; mccGroup?: ValueOf; @@ -4025,6 +4027,7 @@ function getTransactionDetails( waypoints: getWaypoints(transaction), customUnitRateID: getRateID(transaction), category: getCategory(transaction), + reimbursable: getReimbursable(transaction), billable: getBillable(transaction), tag: getTag(transaction), mccGroup: getMCCGroup(transaction), @@ -4222,6 +4225,7 @@ function canEditFieldOfMoneyRequest( CONST.EDIT_REQUEST_FIELD.RECEIPT, CONST.EDIT_REQUEST_FIELD.DISTANCE, CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, + CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, CONST.EDIT_REQUEST_FIELD.REPORT, ]; @@ -4249,11 +4253,20 @@ function canEditFieldOfMoneyRequest( return false; } + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.REIMBURSABLE && isClosedReport(moneyRequestReport)) { + return false; + } + // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 // eslint-disable-next-line deprecation/deprecation const policy = getPolicy(moneyRequestReport?.policyID); const isAdmin = isExpenseReport(moneyRequestReport) && policy?.role === CONST.POLICY.ROLE.ADMIN; const isManager = isExpenseReport(moneyRequestReport) && currentUserAccountID === moneyRequestReport?.managerID; + const isRequestor = currentUserAccountID === reportAction?.actorAccountID; + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.REIMBURSABLE) { + return isAdmin || isManager || isRequestor; + } if ((fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT || fieldToEdit === CONST.EDIT_REQUEST_FIELD.CURRENCY) && isDistanceRequest(transaction)) { return isAdmin || isManager; @@ -4267,7 +4280,6 @@ function canEditFieldOfMoneyRequest( } if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { - const isRequestor = currentUserAccountID === reportAction?.actorAccountID; return ( !isInvoiceReport(moneyRequestReport) && !isReceiptBeingScanned(transaction) && @@ -4820,6 +4832,12 @@ function getModifiedExpenseOriginalMessage( originalMessage.currency = getCurrency(oldTransaction); } + if ('reimbursable' in transactionChanges) { + const oldReimbursable = getReimbursable(oldTransaction); + originalMessage.oldReimbursable = oldReimbursable ? translateLocal('common.reimbursable').toLowerCase() : translateLocal('iou.nonReimbursable').toLowerCase(); + originalMessage.reimbursable = transactionChanges?.reimbursable ? translateLocal('common.reimbursable').toLowerCase() : translateLocal('iou.nonReimbursable').toLowerCase(); + } + if ('billable' in transactionChanges) { const oldBillable = getBillable(oldTransaction); originalMessage.oldBillable = oldBillable ? translateLocal('common.billable').toLowerCase() : translateLocal('common.nonBillable').toLowerCase(); @@ -5215,6 +5233,9 @@ function getReportNameInternal({ if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE)) { return getPolicyChangeLogDefaultBillableMessage(parentReportAction); } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE)) { + return getPolicyChangeLogDefaultReimbursableMessage(parentReportAction); + } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED)) { return getPolicyChangeLogDefaultTitleEnforcedMessage(parentReportAction); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 854e890afaf2..3fc13362eef1 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -33,6 +33,7 @@ import { getOriginalMessage, getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogEmployeeLeftMessage, @@ -825,6 +826,8 @@ function getOptionData({ result.alternateText = getPolicyChangeLogMaxExpenseAmountMessage(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE) { result.alternateText = getPolicyChangeLogDefaultBillableMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE) { + result.alternateText = getPolicyChangeLogDefaultReimbursableMessage(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED) { result.alternateText = getPolicyChangeLogDefaultTitleEnforcedMessage(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY) { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 4ab47fc02151..84f135dbe130 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -522,6 +522,10 @@ function getUpdatedTransaction({ updatedTransaction.taxCode = transactionChanges.taxCode; } + if (Object.hasOwn(transactionChanges, 'reimbursable') && typeof transactionChanges.reimbursable === 'boolean') { + updatedTransaction.reimbursable = transactionChanges.reimbursable; + } + if (Object.hasOwn(transactionChanges, 'billable') && typeof transactionChanges.billable === 'boolean') { updatedTransaction.billable = transactionChanges.billable; } @@ -562,6 +566,7 @@ function getUpdatedTransaction({ ...(Object.hasOwn(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'waypoints') && {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(Object.hasOwn(transactionChanges, 'reimbursable') && {reimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'billable') && {billable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), @@ -770,7 +775,7 @@ function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Atten /** * Return the reimbursable value. Defaults to true to match BE logic. */ -function getReimbursable(transaction: Transaction): boolean { +function getReimbursable(transaction: OnyxInputOrEntry): boolean { return transaction?.reimbursable ?? true; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 002c522d2b72..d02e23df8dec 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -250,6 +250,7 @@ type BaseTransactionParams = { taxCode?: string; taxAmount?: number; billable?: boolean; + reimbursable?: boolean; customUnitRateID?: string; }; @@ -279,6 +280,7 @@ type MoneyRequestInformation = { createdReportActionIDForThread: string | undefined; onyxData: OnyxData; billable?: boolean; + reimbursable?: boolean; }; type TrackExpenseInformation = { @@ -441,7 +443,6 @@ type RequestMoneyInformation = { policyParams?: BasePolicyParams; gpsPoints?: GPSPoint; action?: IOUAction; - reimbursible?: boolean; transactionParams: RequestMoneyTransactionParams; isRetry?: boolean; shouldPlaySound?: boolean; @@ -555,6 +556,7 @@ type TrackExpenseTransactionParams = { taxCode?: string; taxAmount?: number; billable?: boolean; + reimbursable?: boolean; validWaypoints?: WaypointCollection; gpsPoints?: GPSPoint; actionableWhisperReportActionID?: string; @@ -658,6 +660,7 @@ type StartSplitBilActionParams = { receipt: Receipt; existingSplitChatReportID?: string; billable?: boolean; + reimbursable?: boolean; category: string | undefined; tag: string | undefined; currency: string; @@ -1108,6 +1111,10 @@ function setMoneyRequestBillable(transactionID: string, billable: boolean) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {billable}); } +function setMoneyRequestReimbursable(transactionID: string, reimbursable: boolean) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {reimbursable}); +} + function setMoneyRequestParticipants(transactionID: string, participants: Participant[] = [], isTestTransaction = false) { // We should change the reportID and isFromGlobalCreate of the test transaction since this flow can start inside an existing report return Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { @@ -3219,6 +3226,7 @@ function getSendInvoiceInformation( taxCode, taxAmount, billable, + reimbursable: true, filename, }, }); @@ -3336,6 +3344,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma taxCode, taxAmount, billable, + reimbursable = true, linkedTrackedExpenseReportAction, } = transactionParams; @@ -3379,11 +3388,12 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma const shouldCreateNewMoneyRequestReport = isSplitExpense ? false : shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest); if (!iouReport || shouldCreateNewMoneyRequestReport) { + const nonReimbursableTotal = reimbursable ? 0 : amount; iouReport = isPolicyExpenseChat - ? buildOptimisticExpenseReport(chatReport.reportID, chatReport.policyID, payeeAccountID, amount, currency, undefined, undefined, optimisticIOUReportID) + ? buildOptimisticExpenseReport(chatReport.reportID, chatReport.policyID, payeeAccountID, amount, currency, nonReimbursableTotal, undefined, optimisticIOUReportID) : buildOptimisticIOUReport(payeeAccountID, payerAccountID, amount, chatReport.reportID, currency, undefined, undefined, optimisticIOUReportID); } else if (isPolicyExpenseChat) { - // Splitting doesn’t affect the amount, so no adjustment is needed + // Splitting doesn't affect the amount, so no adjustment is needed // The amount remains constant after the split if (!isSplitExpense) { iouReport = {...iouReport}; @@ -3392,6 +3402,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined) { iouReport.total -= amount; } + iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - (reimbursable ? 0 : amount); if (typeof iouReport.unheldTotal === 'number') { iouReport.unheldTotal -= amount; @@ -3425,6 +3436,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma source, taxAmount: isExpenseReport(iouReport) ? -(taxAmount ?? 0) : taxAmount, billable, + reimbursable: isPolicyExpenseChat ? reimbursable : true, pendingFields: isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, }, isDemoTransactionParam: isSelectedManagerMcTest(participant.login) || transactionParams.receipt?.isTestDriveReceipt, @@ -3598,7 +3610,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; const {policy, policyCategories, policyTagList} = policyParams; const {destinations: recentlyUsedDestinations} = recentlyUsedParams; - const {comment = '', currency, created, category, tag, customUnit, billable, attendees} = transactionParams; + const {comment = '', currency, created, category, tag, customUnit, billable, attendees, reimbursable} = transactionParams; const amount = computePerDiemExpenseAmount(customUnit); const merchant = computePerDiemExpenseMerchant(customUnit, policy); @@ -3676,6 +3688,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI tag, customUnit, billable, + reimbursable, pendingFields: {subRates: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, attendees, }, @@ -3793,6 +3806,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI failureData, }, billable, + reimbursable, }; } @@ -4184,6 +4198,9 @@ function getUpdateMoneyRequestParams( if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { updatedMoneyRequestReport.nonReimbursableTotal -= diff; } + if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { + updatedMoneyRequestReport.nonReimbursableTotal += updatedTransaction.reimbursable ? updatedTransaction.amount : -updatedTransaction.amount; + } if (!isTransactionOnHold) { if (typeof updatedMoneyRequestReport.unheldTotal === 'number') { updatedMoneyRequestReport.unheldTotal -= diff; @@ -4191,6 +4208,9 @@ function getUpdateMoneyRequestParams( if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { updatedMoneyRequestReport.unheldNonReimbursableTotal -= diff; } + if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { + updatedMoneyRequestReport.unheldNonReimbursableTotal += updatedTransaction.reimbursable ? updatedTransaction.amount : -updatedTransaction.amount; + } } } else { updatedMoneyRequestReport = updateIOUOwnerAndTotal( @@ -4370,6 +4390,7 @@ function getUpdateMoneyRequestParams( const hasModifiedCurrency = 'currency' in transactionChanges; const hasModifiedComment = 'comment' in transactionChanges; + const hasModifiedReimbursable = 'reimbursable' in transactionChanges; const hasModifiedTaxCode = 'taxCode' in transactionChanges; const hasModifiedDate = 'date' in transactionChanges; @@ -4387,6 +4408,7 @@ function getUpdateMoneyRequestParams( hasModifiedCurrency || hasModifiedAmount || hasModifiedCreated || + hasModifiedReimbursable || hasModifiedTaxCode) ) { const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; @@ -4430,13 +4452,17 @@ function getUpdateMoneyRequestParams( }, }); } - if (violationsOnyxData && (iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN) === CONST.REPORT.STATUS_NUM.OPEN) { + if ( + violationsOnyxData && + ((iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN) === CONST.REPORT.STATUS_NUM.OPEN || + (hasModifiedReimbursable && iouReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) + ) { const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`] ?? {}; const shouldFixViolations = Array.isArray(violationsOnyxData.value) && violationsOnyxData.value.length > 0; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`, - value: buildNextStep(iouReport ?? undefined, iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN, shouldFixViolations), + value: buildNextStep(updatedMoneyRequestReport ?? iouReport ?? undefined, iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN, shouldFixViolations), }); failureData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -4678,6 +4704,24 @@ function updateMoneyRequestBillable( API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); } +function updateMoneyRequestReimbursable( + transactionID: string | undefined, + transactionThreadReportID: string | undefined, + value: boolean, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + if (!transactionID || !transactionThreadReportID) { + return; + } + const transactionChanges: TransactionChanges = { + reimbursable: value, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); +} + /** Updates the merchant field of an expense */ function updateMoneyRequestMerchant( transactionID: string, @@ -5004,6 +5048,7 @@ type ConvertTrackedWorkspaceParams = { waypoints?: string; customUnitID?: string; customUnitRateID?: string; + reimbursable?: boolean; }; type AddTrackedExpenseToPolicyParam = { @@ -5455,7 +5500,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { transactionParams, gpsPoints, action, - reimbursible, shouldHandleNavigation = true, backToReport, shouldPlaySound = true, @@ -5479,6 +5523,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { taxCode = '', taxAmount = 0, billable, + reimbursable, created, attendees, actionableWhisperReportActionID, @@ -5572,6 +5617,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { waypoints: sanitizedWaypoints, customUnitID: getDistanceRateCustomUnit(policyParams?.policy)?.customUnitID, customUnitRateID, + reimbursable, } : undefined; convertTrackedExpenseToRequest({ @@ -5643,7 +5689,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, transactionThreadReportID, createdReportActionIDForThread, - reimbursible, + reimbursable, description: parsedComment, attendees: attendees ? JSON.stringify(attendees) : undefined, isTestDrive, @@ -5712,6 +5758,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf createdReportActionIDForThread, onyxData, billable, + reimbursable, } = getPerDiemExpenseInformation({ parentChatReport: currentChatReport, participantParams, @@ -5752,6 +5799,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf transactionThreadReportID, createdReportActionIDForThread, billable, + reimbursable, attendees: attendees ? JSON.stringify(attendees) : undefined, }; @@ -5865,6 +5913,7 @@ function trackExpense(params: CreateTrackExpenseParams) { taxCode = '', taxAmount = 0, billable, + reimbursable, gpsPoints, validWaypoints, actionableWhisperReportActionID, @@ -5992,6 +6041,7 @@ function trackExpense(params: CreateTrackExpenseParams) { category, tag, billable, + reimbursable, receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, waypoints: sanitizedWaypoints, customUnitRateID: mileageRate, @@ -6039,6 +6089,7 @@ function trackExpense(params: CreateTrackExpenseParams) { category, tag, billable, + reimbursable, receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, waypoints: sanitizedWaypoints, customUnitRateID: mileageRate, @@ -6635,6 +6686,7 @@ type SplitBillActionsParams = { category?: string; tag?: string; billable?: boolean; + reimbursable?: boolean; iouRequestType?: IOURequestType; existingSplitChatReportID?: string; splitShares?: SplitShares; @@ -6660,6 +6712,7 @@ function splitBill({ category = '', tag = '', billable = false, + reimbursable = false, iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, existingSplitChatReportID, splitShares = {}, @@ -6683,6 +6736,7 @@ function splitBill({ tag, splitShares, billable, + reimbursable, iouRequestType, taxCode, taxAmount, @@ -6700,6 +6754,7 @@ function splitBill({ created, tag, billable, + reimbursable, transactionID: splitData.transactionID, reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, @@ -6735,6 +6790,7 @@ function splitBillAndOpenReport({ category = '', tag = '', billable = false, + reimbursable = false, iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, splitShares = {}, splitPayerAccountIDs = [], @@ -6758,6 +6814,7 @@ function splitBillAndOpenReport({ tag, splitShares, billable, + reimbursable, iouRequestType, taxCode, taxAmount, @@ -6775,6 +6832,7 @@ function splitBillAndOpenReport({ category, tag, billable, + reimbursable, transactionID: splitData.transactionID, reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, @@ -6807,6 +6865,7 @@ function startSplitBill({ receipt, existingSplitChatReportID, billable = false, + reimbursable = false, category = '', tag = '', currency, @@ -6837,6 +6896,7 @@ function startSplitBill({ taxCode, taxAmount, billable, + reimbursable, filename, }, }); @@ -6972,6 +7032,7 @@ function startSplitBill({ receipt: receiptObject, existingSplitChatReportID, billable, + reimbursable, category, tag, currency, @@ -7121,6 +7182,7 @@ function startSplitBill({ currency, isFromGroupDM: !existingSplitChatReport, billable, + reimbursable, ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), chatType: splitChatReport?.chatType, taxCode, @@ -7446,7 +7508,23 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const {policy, policyCategories, policyTagList} = policyParams; const parsedComment = getParsedComment(transactionParams.comment); transactionParams.comment = parsedComment; - const {amount, comment, currency, created, category, tag, taxAmount, taxCode, merchant, billable, validWaypoints, customUnitRateID = '', splitShares = {}, attendees} = transactionParams; + const { + amount, + comment, + currency, + created, + category, + tag, + taxAmount, + taxCode, + merchant, + billable, + reimbursable, + validWaypoints, + customUnitRateID = '', + splitShares = {}, + attendees, + } = transactionParams; // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); @@ -7505,6 +7583,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest taxCode, taxAmount, billable, + reimbursable, splits: JSON.stringify(splits), chatType: splitData.chatType, description: parsedComment, @@ -7550,6 +7629,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest taxCode, taxAmount, billable, + reimbursable, attendees, }, }); @@ -7572,6 +7652,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest taxCode, taxAmount, billable, + reimbursable, transactionThreadReportID, createdReportActionIDForThread, payerEmail, @@ -11778,6 +11859,7 @@ function initSplitExpense(transaction: OnyxEntry, isOpenC participants: transaction?.participants, attendees: transactionDetails?.attendees as Attendee[], reportID: originalTransaction?.reportID, + reimbursable: transactionDetails?.reimbursable, }, }); @@ -11825,6 +11907,7 @@ function initSplitExpense(transaction: OnyxEntry, isOpenC participants: transaction?.participants, attendees: transactionDetails?.attendees as Attendee[], reportID, + reimbursable: transactionDetails?.reimbursable, }, }); @@ -12025,6 +12108,7 @@ function saveSplitTransactions(draftTransaction: OnyxEntry; + type BuildPolicyDataOptions = { policyOwnerEmail?: string; makeMeAdmin?: boolean; @@ -1899,7 +1902,8 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol }, areWorkflowsEnabled: shouldEnableWorkflowsByDefault, defaultBillable: false, - disabledFields: {defaultBillable: true}, + defaultReimbursable: true, + disabledFields: {defaultBillable: true, reimbursable: false}, requiresCategory: true, }, }, @@ -2022,7 +2026,8 @@ function buildPolicyData(options: BuildPolicyDataOptions = {}) { areReportFieldsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, defaultBillable: false, - disabledFields: {defaultBillable: true}, + defaultReimbursable: true, + disabledFields: {defaultBillable: true, reimbursable: false}, avatarURL: file?.uri, originalFileName: file?.name, ...optimisticMccGroupData.optimisticData, @@ -4544,6 +4549,96 @@ function setPolicyBillableMode(policyID: string, defaultBillable: boolean) { API.write(WRITE_COMMANDS.SET_POLICY_BILLABLE_MODE, parameters, onyxData); } +function getCashExpenseReimbursableMode(policyID: string): PolicyCashExpenseMode | undefined { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + if (policy.defaultReimbursable && !policy.disabledFields?.reimbursable) { + return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT; + } + + if (!policy.disabledFields?.reimbursable && !policy.defaultReimbursable) { + return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.NON_REIMBURSABLE_DEFAULT; + } + + if (policy.defaultReimbursable && policy.disabledFields?.reimbursable) { + return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE; + } + + return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_NON_REIMBURSABLE; +} + +/** + * Call the API to enable or disable the reimbursable mode for the given policy + * @param policyID - id of the policy to enable or disable the reimbursable mode + * @param reimbursableMode - reimbursable mode to set for the given policy + */ +function setPolicyReimbursableMode(policyID: string, reimbursableMode: PolicyCashExpenseMode) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + const originalDefaultReimbursable = policy?.defaultReimbursable; + const originalDefaultReimbursableDisabled = policy?.disabledFields?.reimbursable; + + const defaultReimbursable = + reimbursableMode === CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT || reimbursableMode === CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE; + const reimbursableDisabled = + reimbursableMode === CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE || + reimbursableMode === CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_NON_REIMBURSABLE; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + defaultReimbursable, + disabledFields: { + reimbursable: reimbursableDisabled, + }, + pendingFields: { + defaultReimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + disabledFields: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + defaultReimbursable: null, + disabledFields: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + disabledFields: {reimbursable: originalDefaultReimbursableDisabled}, + defaultReimbursable: originalDefaultReimbursable, + pendingFields: {defaultReimbursable: null, disabledFields: null}, + errorFields: {defaultReimbursable: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters: SetPolicyCashExpenseModeParams = { + policyID, + defaultReimbursable, + disabledFields: JSON.stringify({ + reimbursable: reimbursableDisabled, + }), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_REIMBURSABLE_MODE, parameters, onyxData); +} + /** * Call the API to disable the billable mode for the given policy * @param policyID - id of the policy to enable or disable the billable mode @@ -5751,6 +5846,8 @@ export { openPolicyReceiptPartnersPage, setIsComingFromGlobalReimbursementsFlow, setPolicyAttendeeTrackingEnabled, + setPolicyReimbursableMode, + getCashExpenseReimbursableMode, updateInterestedFeatures, clearPolicyTitleFieldError, }; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1c22e877a9ce..1ebd6da77f65 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -24,7 +24,7 @@ import { findSelfDMReportID, getReportTransactions, } from '@libs/ReportUtils'; -import {getAmount, waypointHasValidAddress} from '@libs/TransactionUtils'; +import {getAmount, isOnHold, waypointHasValidAddress} from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -619,6 +619,8 @@ function changeTransactionsReport(transactionIDs: string[], reportID: string, po const transactions = transactionIDs.map((id) => allTransactions?.[id]).filter((t): t is NonNullable => t !== undefined); const transactionIDToReportActionAndThreadData: Record = {}; const updatedReportTotals: Record = {}; + const updatedReportNonReimbursableTotals: Record = {}; + const updatedReportUnheldNonReimbursableTotals: Record = {}; // Store current violations for each transaction to restore on failure const currentTransactionViolations: Record = {}; @@ -790,6 +792,7 @@ function changeTransactionsReport(transactionIDs: string[], reportID: string, po }); } + let transactionReimbursable = transaction.reimbursable; // 2. Calculate transaction violations if moving transaction to a workspace if (isPaidGroupPolicy(policy) && policy?.id) { const violationData = ViolationsUtils.getViolationsOnyxData(transaction, allTransactionViolations, policy, policyTagList, policyCategories, policyHasDependentTags, false); @@ -811,6 +814,23 @@ function changeTransactionsReport(transactionIDs: string[], reportID: string, po if (transactionHasViolations && hasOtherViolationsBesideDuplicates) { shouldFixViolations = true; } + if (policy?.disabledFields?.reimbursable) { + transactionReimbursable = policy?.defaultReimbursable; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + reimbursable: transactionReimbursable, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + reimbursable: transaction?.reimbursable, + }, + }); + } } // 3. Keep track of the new report totals @@ -820,9 +840,21 @@ function changeTransactionsReport(transactionIDs: string[], reportID: string, po if (oldReport) { updatedReportTotals[oldReportID] = (updatedReportTotals[oldReportID] ? updatedReportTotals[oldReportID] : (oldReport?.total ?? 0)) + transactionAmount; + updatedReportNonReimbursableTotals[oldReportID] = + (updatedReportNonReimbursableTotals[oldReportID] ? updatedReportNonReimbursableTotals[oldReportID] : (oldReport?.nonReimbursableTotal ?? 0)) + + (transaction?.reimbursable ? 0 : transactionAmount); + updatedReportUnheldNonReimbursableTotals[oldReportID] = + (updatedReportUnheldNonReimbursableTotals[oldReportID] ? updatedReportUnheldNonReimbursableTotals[oldReportID] : (oldReport?.unheldNonReimbursableTotal ?? 0)) + + (transaction?.reimbursable && !isOnHold(transaction) ? 0 : transactionAmount); } if (reportID && newReport) { updatedReportTotals[targetReportID] = (updatedReportTotals[targetReportID] ? updatedReportTotals[targetReportID] : (newReport.total ?? 0)) - transactionAmount; + updatedReportNonReimbursableTotals[targetReportID] = + (updatedReportNonReimbursableTotals[targetReportID] ? updatedReportNonReimbursableTotals[targetReportID] : (newReport.nonReimbursableTotal ?? 0)) - + (transactionReimbursable ? 0 : transactionAmount); + updatedReportUnheldNonReimbursableTotals[targetReportID] = + (updatedReportUnheldNonReimbursableTotals[targetReportID] ? updatedReportUnheldNonReimbursableTotals[targetReportID] : (newReport.unheldNonReimbursableTotal ?? 0)) - + (transactionReimbursable || !isOnHold(transaction) ? 0 : transactionAmount); } // 4. Optimistically update the IOU action reportID @@ -1055,6 +1087,36 @@ function changeTransactionsReport(transactionIDs: string[], reportID: string, po }); }); + Object.entries(updatedReportNonReimbursableTotals).forEach(([reportIDToUpdate, total]) => { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`, + value: {nonReimbursableTotal: total}, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`, + value: {nonReimbursableTotal: allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`]?.nonReimbursableTotal}, + }); + }); + + Object.entries(updatedReportUnheldNonReimbursableTotals).forEach(([reportIDToUpdate, total]) => { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`, + value: {unheldNonReimbursableTotal: total}, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`, + value: { + unheldNonReimbursableTotal: allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`]?.unheldNonReimbursableTotal, + }, + }); + }); + const reportTransactions = getReportTransactions(reportID); reportTransactions.forEach((transaction) => { if (!isPaidGroupPolicy(policy) || !policy?.id) { diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 32a4949f2532..61e6b81d840b 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -20,6 +20,7 @@ function SearchTransactionsChangeReport() { const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [allReportNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP, {canBeMissing: true}); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const transactionsReports = useMemo(() => { const reports = Object.values(selectedTransactions).reduce((acc, transaction) => { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; @@ -37,7 +38,7 @@ function SearchTransactionsChangeReport() { } const reportNextStep = allReportNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${item.value}`]; - changeTransactionsReport(selectedTransactionsKeys, item.value, undefined, reportNextStep); + changeTransactionsReport(selectedTransactionsKeys, item.value, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`], reportNextStep); clearSelectedTransactions(); Navigation.goBack(); diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 5467f5f9a8cd..712b62493b0b 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -113,6 +113,7 @@ function SubmitDetailsPage({ taxCode: transactionTaxCode, taxAmount: transactionTaxAmount, billable: transaction.billable, + reimbursable: transaction.reimbursable, merchant: transaction.merchant ?? '', created: transaction.created, actionableWhisperReportActionID: transaction.actionableWhisperReportActionID, @@ -138,6 +139,7 @@ function SubmitDetailsPage({ taxCode: transactionTaxCode, taxAmount: transactionTaxAmount, billable: transaction.billable, + reimbursable: transaction.reimbursable, merchant: transaction.merchant ?? '', created: transaction.created, actionableWhisperReportActionID: transaction.actionableWhisperReportActionID, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 222c2f416502..c3e83e8836b2 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -36,6 +36,7 @@ import { getOriginalMessage, getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogMaxExpenseAmountMessage, @@ -567,6 +568,8 @@ const ContextMenuActions: ContextMenuAction[] = [ Clipboard.setString(getPolicyChangeLogMaxExpenseAmountMessage(reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE) { Clipboard.setString(getPolicyChangeLogDefaultBillableMessage(reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE) { + Clipboard.setString(getPolicyChangeLogDefaultReimbursableMessage(reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED) { Clipboard.setString(getPolicyChangeLogDefaultTitleEnforcedMessage(reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 4fe86a3196a2..618d644a4d00 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -68,6 +68,7 @@ import { getOriginalMessage, getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogMaxExpenseAmountMessage, @@ -1148,6 +1149,8 @@ function PureReportActionItem({ children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE)) { children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE)) { + children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED)) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx index 32b3ad25cc59..3a22829e1416 100644 --- a/src/pages/iou/request/step/IOURequestEditReport.tsx +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -26,6 +26,7 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: false}); const [reportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${reportID}`, {canBeMissing: true}); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const selectReport = (item: TransactionGroupListItem) => { if (selectedTransactionIDs.length === 0 || item.value === reportID) { @@ -33,7 +34,7 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { return; } - changeTransactionsReport(selectedTransactionIDs, item.value, undefined, reportNextStep); + changeTransactionsReport(selectedTransactionIDs, item.value, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`], reportNextStep); turnOffMobileSelectionMode(); clearSelectedTransactions(true); Navigation.dismissModal(); diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 19595913cd90..6c4760453e08 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -107,6 +107,7 @@ function IOURequestEditReportCommon({ alternateText: getPolicyName({report}) ?? matchingOption?.alternateText, value: report.reportID, isSelected: onlyReport && report.reportID === onlyReport?.reportID, + policyID: matchingOption?.policyID ?? report.policyID, }; }); }, [outstandingReportsByPolicyID, debouncedSearchValue, expenseReports, onlyReport, options.reports, localeCompare]); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 388f3b7ddd6a..c2af592d5d24 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -36,6 +36,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; +import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {generateReportID, getReportOrDraftReport, isProcessingReport, isReportOutstanding, isSelectedManagerMcTest} from '@libs/ReportUtils'; import {getAttendees, getDefaultTaxCode, getRateID, getRequestType, getValidWaypoints, hasReceipt, isScanRequest} from '@libs/TransactionUtils'; import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; @@ -51,6 +52,7 @@ import { setMoneyRequestBillable, setMoneyRequestCategory, setMoneyRequestReceipt, + setMoneyRequestReimbursable, splitBill, splitBillAndOpenReport, startMoneyRequest, @@ -243,6 +245,13 @@ function IOURequestStepConfirmation({ }); }, [transactionIDs, defaultBillable]); + useEffect(() => { + const defaultReimbursable = isPolicyExpenseChat && isPaidGroupPolicy(policy) ? !!policy?.defaultReimbursable : true; + transactionIDs.forEach((transactionID) => { + setMoneyRequestReimbursable(transactionID, defaultReimbursable); + }); + }, [transactionIDs, isPolicyExpenseChat]); + useEffect(() => { // Exit early if the transaction is still loading if (isLoadingTransaction) { @@ -487,6 +496,7 @@ function IOURequestStepConfirmation({ taxCode: transactionTaxCode, taxAmount: transactionTaxAmount, billable: item.billable, + reimbursable: item.reimbursable, actionableWhisperReportActionID: item.actionableWhisperReportActionID, linkedTrackedExpenseReportAction: item.linkedTrackedExpenseReportAction, linkedTrackedExpenseReportID: item.linkedTrackedExpenseReportID, @@ -553,6 +563,7 @@ function IOURequestStepConfirmation({ tag: transaction.tag, customUnit: transaction.comment?.customUnit, billable: transaction.billable, + reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, }, }); @@ -596,6 +607,7 @@ function IOURequestStepConfirmation({ taxCode: transactionTaxCode, taxAmount: transactionTaxAmount, billable: item.billable, + reimbursable: item.reimbursable, gpsPoints, validWaypoints: Object.keys(item?.comment?.waypoints ?? {}).length ? getValidWaypoints(item.comment?.waypoints, true) : undefined, actionableWhisperReportActionID: item.actionableWhisperReportActionID, @@ -659,6 +671,7 @@ function IOURequestStepConfirmation({ splitShares: transaction.splitShares, validWaypoints: getValidWaypoints(transaction.comment?.waypoints, true), billable: transaction.billable, + reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, }, backToReport, @@ -723,6 +736,7 @@ function IOURequestStepConfirmation({ receipt: currentTransactionReceiptFile, existingSplitChatReportID: report?.reportID, billable: transaction.billable, + reimbursable: transaction.reimbursable, category: transaction.category, tag: transaction.tag, currency: transaction.currency, @@ -750,6 +764,7 @@ function IOURequestStepConfirmation({ tag: transaction.tag, existingSplitChatReportID: report?.reportID, billable: transaction.billable, + reimbursable: transaction.reimbursable, iouRequestType: transaction.iouRequestType, splitShares: transaction.splitShares, splitPayerAccountIDs: transaction.splitPayerAccountIDs ?? [], @@ -775,6 +790,7 @@ function IOURequestStepConfirmation({ category: transaction.category, tag: transaction.tag, billable: !!transaction.billable, + reimbursable: !!transaction.reimbursable, iouRequestType: transaction.iouRequestType, splitShares: transaction.splitShares, splitPayerAccountIDs: transaction.splitPayerAccountIDs, @@ -938,6 +954,13 @@ function IOURequestStepConfirmation({ [currentTransactionID], ); + const setReimbursable = useCallback( + (reimbursable: boolean) => { + setMoneyRequestReimbursable(currentTransactionID, reimbursable); + }, + [currentTransactionID], + ); + // This loading indicator is shown because the transaction originalCurrency is being updated later than the component mounts. // To prevent the component from rendering with the wrong currency, we show a loading indicator until the correct currency is set. const isLoading = !!transaction?.originalCurrency; @@ -1117,6 +1140,8 @@ function IOURequestStepConfirmation({ payeePersonalDetails={payeePersonalDetails} isConfirmed={isConfirmed} isConfirming={isConfirming} + iouIsReimbursable={transaction?.reimbursable} + onToggleReimbursable={setReimbursable} expensesNumber={transactions.length} isReceiptEditable /> diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index b520363aa74c..8a9095975f2c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -335,6 +335,7 @@ function IOURequestStepDistance({ merchant: translate('iou.fieldPending'), receipt: {}, billable: false, + reimbursable: true, validWaypoints: getValidWaypoints(waypoints, true), customUnitRateID, attendees: transaction?.comment?.attendees, @@ -359,6 +360,7 @@ function IOURequestStepDistance({ currency: transaction?.currency ?? 'USD', merchant: translate('iou.fieldPending'), billable: !!policy?.defaultBillable, + reimbursable: !!policy?.defaultReimbursable, validWaypoints: getValidWaypoints(waypoints, true), customUnitRateID: DistanceRequestUtils.getCustomUnitRateID({reportID: report.reportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates}), splitShares: transaction?.splitShares, diff --git a/src/pages/iou/request/step/IOURequestStepReport.tsx b/src/pages/iou/request/step/IOURequestStepReport.tsx index 7c56350c2a27..5d157ce29e70 100644 --- a/src/pages/iou/request/step/IOURequestStepReport.tsx +++ b/src/pages/iou/request/step/IOURequestStepReport.tsx @@ -29,6 +29,7 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const reportID = isUnreported ? transaction?.participants?.at(0)?.reportID : transaction?.reportID; const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`, {canBeMissing: false}); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const isEditing = action === CONST.IOU.ACTION.EDIT; const isCreateReport = action === CONST.IOU.ACTION.CREATE; @@ -92,7 +93,7 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { ); if (isEditing) { - changeTransactionsReport([transaction.transactionID], item.value); + changeTransactionsReport([transaction.transactionID], item.value, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]); } }); }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 3b8245063ef0..501eb275dbef 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -297,6 +297,7 @@ function IOURequestStepScan({ policy: OnyxEntry; }, billable?: boolean, + reimbursable = true, ) => { files.forEach((receiptFile: ReceiptFile, index) => { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); @@ -318,6 +319,7 @@ function IOURequestStepScan({ created: transaction?.created, receipt, billable, + reimbursable, ...(gpsPoints ?? {}), }, ...(policyParams ?? {}), diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 29d22f87d7fe..b252cb77e641 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -353,6 +353,7 @@ function IOURequestStepScan({ policy: OnyxEntry; }, billable?: boolean, + reimbursable = true, ) => { files.forEach((receiptFile: ReceiptFile, index) => { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); @@ -374,6 +375,7 @@ function IOURequestStepScan({ created: transaction?.created, receipt, billable, + reimbursable, ...(gpsPoints ?? {}), }, ...(policyParams ?? {}), @@ -397,6 +399,7 @@ function IOURequestStepScan({ merchant: '', receipt, billable, + reimbursable, }, shouldHandleNavigation: index === files.length - 1, backToReport, @@ -484,7 +487,7 @@ function IOURequestStepScan({ lat: successData.coords.latitude, long: successData.coords.longitude, }; - createTransaction(files, participant, gpsPoints, policyParams, false); + createTransaction(files, participant, gpsPoints, policyParams, false, true); }, (errorData) => { Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData); diff --git a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx index d6ea5e4f62d8..fb7ba65b7331 100644 --- a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx +++ b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx @@ -13,7 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {openExternalLink} from '@libs/actions/Link'; -import {setPolicyAttendeeTrackingEnabled, setWorkspaceEReceiptsEnabled} from '@libs/actions/Policy/Policy'; +import {getCashExpenseReimbursableMode, setPolicyAttendeeTrackingEnabled, setWorkspaceEReceiptsEnabled} from '@libs/actions/Policy/Policy'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {ThemeStyles} from '@styles/index'; @@ -100,6 +100,8 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection return translate('workspace.rules.individualExpenseRules.maxExpenseAgeDays', {count: policy?.maxExpenseAge ?? 0}); }, [policy?.maxExpenseAge, translate]); + const reimbursableMode = getCashExpenseReimbursableMode(policyID) ?? CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT; + const reimbursableModeText = translate(`workspace.rules.individualExpenseRules.${reimbursableMode}`); const billableModeText = translate(`workspace.rules.individualExpenseRules.${policy?.defaultBillable ? 'billable' : 'nonBillable'}`); const prohibitedExpenses = useMemo(() => { @@ -152,6 +154,12 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection action: () => Navigation.navigate(ROUTES.RULES_MAX_EXPENSE_AGE.getRoute(policyID)), pendingAction: policy?.pendingFields?.maxExpenseAge, }, + { + title: reimbursableModeText, + descriptionTranslationKey: 'workspace.rules.individualExpenseRules.cashExpenseDefault', + action: () => Navigation.navigate(ROUTES.RULES_REIMBURSABLE_DEFAULT.getRoute(policyID)), + pendingAction: policy?.pendingFields?.defaultReimbursable, + }, { title: billableModeText, descriptionTranslationKey: 'workspace.rules.individualExpenseRules.billableDefault', diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx new file mode 100644 index 000000000000..0925058a0802 --- /dev/null +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getCashExpenseReimbursableMode, setPolicyReimbursableMode} from '@libs/actions/Policy/Policy'; +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 type SCREENS from '@src/SCREENS'; + +type RulesReimbursableDefaultPageProps = PlatformStackScreenProps; + +function RulesReimbursableDefaultPage({ + route: { + params: {policyID}, + }, +}: RulesReimbursableDefaultPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const reimbursableMode = getCashExpenseReimbursableMode(policyID); + + const ReimbursableModes = Object.values(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES).map((mode) => ({ + text: translate(`workspace.rules.individualExpenseRules.${mode}`), + alternateText: translate(`workspace.rules.individualExpenseRules.${mode}Description`), + value: mode, + isSelected: reimbursableMode === mode, + keyForList: mode, + })); + + return ( + + + Navigation.goBack()} + /> + + {translate('workspace.rules.individualExpenseRules.cashExpenseDefaultDescription')} + + { + setPolicyReimbursableMode(policyID, item.value); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={reimbursableMode} + addBottomSafeAreaPadding + /> + + + ); +} + +RulesReimbursableDefaultPage.displayName = 'RulesReimbursableDefaultPage'; + +export default RulesReimbursableDefaultPage; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 1ad8444dcfbd..72317187f78d 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -383,6 +383,12 @@ type OriginalMessagePolicyChangeLog = { /** New default billable value */ newDefaultBillable?: string; + /** Old default reimbursable value */ + oldDefaultReimbursable?: string; + + /** New default reimbursable value */ + newDefaultReimbursable?: string; + /** value -- returned when updating "Auto-approve compliant reports" */ value?: boolean; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 7960393935f1..8336a3e5af3d 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1799,6 +1799,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether transactions should be billable by default */ defaultBillable?: boolean; + /** Whether transactions should be reimbursable by default */ + defaultReimbursable?: boolean; + /** The workspace description */ description?: string; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index a4c2a36bbcaa..ed53774a3b92 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -4288,6 +4288,7 @@ describe('actions/IOU', () => { created: '', merchant, comment, + reimbursable: true, }, shouldGenerateTransactionThreadReport: true, }); @@ -4460,6 +4461,7 @@ describe('actions/IOU', () => { created: '', merchant, comment, + reimbursable: true, }, shouldGenerateTransactionThreadReport: true, });