From c992e340ca696b7ee91ccffefde130bf3e57895c Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Mon, 10 Jun 2024 13:16:27 -0700
Subject: [PATCH] Cover overbudgeted action + make balance movement menus only
 appear on relevant conditions (#2850)

* Make balance movement menus only appear on relevant conditions

* Release notes

* Hide to be budgeted when covering overbudgeted

* Fix typecheck error
---
 .../desktop-client/src/components/Modals.tsx  |  4 +-
 .../budget/rollover/BalanceMenu.tsx           | 24 ++++---
 .../budget/rollover/BalanceMovementMenu.tsx   |  2 +-
 .../components/budget/rollover/CoverMenu.tsx  | 14 ++--
 .../rollover/budgetsummary/ToBudget.tsx       | 17 ++++-
 .../rollover/budgetsummary/ToBudgetMenu.tsx   | 66 +++++++++++++++----
 .../components/mobile/budget/BudgetTable.jsx  |  2 +-
 .../src/components/modals/CoverModal.tsx      | 27 +++-----
 .../modals/RolloverBudgetSummaryModal.tsx     | 25 +++++--
 .../modals/RolloverToBudgetMenuModal.tsx      |  2 +
 .../loot-core/src/client/actions/queries.ts   |  8 ++-
 .../src/client/state-types/modals.d.ts        |  4 +-
 .../loot-core/src/server/budget/actions.ts    | 14 ++++
 packages/loot-core/src/server/budget/app.ts   |  4 ++
 .../src/server/budget/types/handlers.d.ts     |  5 ++
 upcoming-release-notes/2850.md                |  6 ++
 16 files changed, 168 insertions(+), 56 deletions(-)
 create mode 100644 upcoming-release-notes/2850.md

diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index d42fe09eb..cb814ec23 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -544,6 +544,7 @@ export function Modals() {
               <RolloverToBudgetMenuModal
                 modalProps={modalProps}
                 onTransfer={options.onTransfer}
+                onCover={options.onCover}
                 onHoldBuffer={options.onHoldBuffer}
                 onResetHoldBuffer={options.onResetHoldBuffer}
               />
@@ -596,8 +597,9 @@ export function Modals() {
             <CoverModal
               key={name}
               modalProps={modalProps}
-              categoryId={options.categoryId}
+              title={options.title}
               month={options.month}
+              showToBeBudgeted={options.showToBeBudgeted}
               onSubmit={options.onSubmit}
             />
           );
diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx
index 3ec3c5f0f..a0df8edb6 100644
--- a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx
@@ -43,16 +43,14 @@ export function BalanceMenu({
         }
       }}
       items={[
-        {
-          name: 'transfer',
-          text: 'Transfer to another category',
-        },
-        {
-          name: 'carryover',
-          text: carryover
-            ? 'Remove overspending rollover'
-            : 'Rollover overspending',
-        },
+        ...(balance > 0
+          ? [
+              {
+                name: 'transfer',
+                text: 'Transfer to another category',
+              },
+            ]
+          : []),
         ...(balance < 0
           ? [
               {
@@ -61,6 +59,12 @@ export function BalanceMenu({
               },
             ]
           : []),
+        {
+          name: 'carryover',
+          text: carryover
+            ? 'Remove overspending rollover'
+            : 'Rollover overspending',
+        },
       ]}
     />
   );
diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
index 0957b7bc8..ad87c8be6 100644
--- a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
@@ -60,7 +60,7 @@ export function BalanceMovementMenu({
         <CoverMenu
           onClose={onClose}
           onSubmit={fromCategoryId => {
-            onBudgetAction(month, 'cover', {
+            onBudgetAction(month, 'cover-overspending', {
               to: categoryId,
               from: fromCategoryId,
             });
diff --git a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx
index d321357ad..553e28d4e 100644
--- a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx
@@ -8,15 +8,21 @@ import { View } from '../../common/View';
 import { addToBeBudgetedGroup } from '../util';
 
 type CoverMenuProps = {
+  showToBeBudgeted?: boolean;
   onSubmit: (categoryId: string) => void;
   onClose: () => void;
 };
 
-export function CoverMenu({ onSubmit, onClose }: CoverMenuProps) {
+export function CoverMenu({
+  showToBeBudgeted = true,
+  onSubmit,
+  onClose,
+}: CoverMenuProps) {
   const { grouped: originalCategoryGroups } = useCategories();
-  const categoryGroups = addToBeBudgetedGroup(
-    originalCategoryGroups.filter(g => !g.is_income),
-  );
+  let categoryGroups = originalCategoryGroups.filter(g => !g.is_income);
+  categoryGroups = showToBeBudgeted
+    ? addToBeBudgetedGroup(categoryGroups)
+    : categoryGroups;
   const [categoryId, setCategoryId] = useState<string | null>(null);
 
   function submit() {
diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx
index 8d5234b1c..f63f9c18e 100644
--- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx
@@ -6,6 +6,7 @@ import { type CSSProperties } from '../../../../style';
 import { Popover } from '../../../common/Popover';
 import { View } from '../../../common/View';
 import { useSheetValue } from '../../../spreadsheet/useSheetValue';
+import { CoverMenu } from '../CoverMenu';
 import { HoldMenu } from '../HoldMenu';
 import { TransferMenu } from '../TransferMenu';
 
@@ -55,6 +56,7 @@ export function ToBudget({
         {menuOpen === 'actions' && (
           <ToBudgetMenu
             onTransfer={() => setMenuOpen('transfer')}
+            onCover={() => setMenuOpen('cover')}
             onHoldBuffer={() => setMenuOpen('buffer')}
             onResetHoldBuffer={() => {
               onBudgetAction(month, 'reset-hold');
@@ -74,10 +76,21 @@ export function ToBudget({
           <TransferMenu
             initialAmount={availableValue}
             onClose={() => setMenuOpen(null)}
-            onSubmit={(amount, category) => {
+            onSubmit={(amount, categoryId) => {
               onBudgetAction(month, 'transfer-available', {
                 amount,
-                category,
+                category: categoryId,
+              });
+            }}
+          />
+        )}
+        {menuOpen === 'cover' && (
+          <CoverMenu
+            showToBeBudgeted={false}
+            onClose={() => setMenuOpen(null)}
+            onSubmit={categoryId => {
+              onBudgetAction(month, 'cover-overbudgeted', {
+                category: categoryId,
               });
             }}
           />
diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx
index 95cf54a8f..37ddf5507 100644
--- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx
@@ -1,21 +1,59 @@
 import React, { type ComponentPropsWithoutRef } from 'react';
 
+import { rolloverBudget } from 'loot-core/client/queries';
+
 import { Menu } from '../../../common/Menu';
+import { useSheetValue } from '../../../spreadsheet/useSheetValue';
 
 type ToBudgetMenuProps = Omit<
   ComponentPropsWithoutRef<typeof Menu>,
   'onMenuSelect' | 'items'
 > & {
   onTransfer: () => void;
+  onCover: () => void;
   onHoldBuffer: () => void;
   onResetHoldBuffer: () => void;
 };
 export function ToBudgetMenu({
   onTransfer,
+  onCover,
   onHoldBuffer,
   onResetHoldBuffer,
   ...props
 }: ToBudgetMenuProps) {
+  const toBudget = useSheetValue(rolloverBudget.toBudget);
+  const forNextMonth = useSheetValue(rolloverBudget.forNextMonth);
+  const items = [
+    ...(toBudget > 0
+      ? [
+          {
+            name: 'transfer',
+            text: 'Move to a category',
+          },
+          {
+            name: 'buffer',
+            text: 'Hold for next month',
+          },
+        ]
+      : []),
+    ...(toBudget < 0
+      ? [
+          {
+            name: 'cover',
+            text: 'Cover from a category',
+          },
+        ]
+      : []),
+    ...(forNextMonth > 0
+      ? [
+          {
+            name: 'reset-buffer',
+            text: 'Reset next month’s buffer',
+          },
+        ]
+      : []),
+  ];
+
   return (
     <Menu
       {...props}
@@ -24,6 +62,9 @@ export function ToBudgetMenu({
           case 'transfer':
             onTransfer?.();
             break;
+          case 'cover':
+            onCover?.();
+            break;
           case 'buffer':
             onHoldBuffer?.();
             break;
@@ -34,20 +75,17 @@ export function ToBudgetMenu({
             throw new Error(`Unrecognized menu option: ${name}`);
         }
       }}
-      items={[
-        {
-          name: 'transfer',
-          text: 'Move to a category',
-        },
-        {
-          name: 'buffer',
-          text: 'Hold for next month',
-        },
-        {
-          name: 'reset-buffer',
-          text: 'Reset next month’s buffer',
-        },
-      ]}
+      items={
+        items.length > 0
+          ? items
+          : [
+              {
+                name: 'none',
+                text: 'No actions available',
+                disabled: true,
+              },
+            ]
+      }
     />
   );
 }
diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
index 09a120fc1..9a00465f3 100644
--- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
+++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
@@ -373,7 +373,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
         categoryId: category.id,
         month,
         onSubmit: fromCategoryId => {
-          onBudgetAction(month, 'cover', {
+          onBudgetAction(month, 'cover-overspending', {
             to: category.id,
             from: fromCategoryId,
           });
diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx
index 81386d44f..d268472a9 100644
--- a/packages/desktop-client/src/components/modals/CoverModal.tsx
+++ b/packages/desktop-client/src/components/modals/CoverModal.tsx
@@ -15,25 +15,28 @@ import { type CommonModalProps } from '../Modals';
 
 type CoverModalProps = {
   modalProps: CommonModalProps;
-  categoryId: string;
+  title: string;
   month: string;
+  showToBeBudgeted?: boolean;
   onSubmit: (categoryId: string) => void;
 };
 
 export function CoverModal({
   modalProps,
-  categoryId,
+  title,
   month,
+  showToBeBudgeted = true,
   onSubmit,
 }: CoverModalProps) {
   const { grouped: originalCategoryGroups } = useCategories();
   const [categoryGroups, categories] = useMemo(() => {
-    const expenseGroups = addToBeBudgetedGroup(
-      originalCategoryGroups.filter(g => !g.is_income),
-    );
+    let expenseGroups = originalCategoryGroups.filter(g => !g.is_income);
+    expenseGroups = showToBeBudgeted
+      ? addToBeBudgetedGroup(expenseGroups)
+      : expenseGroups;
     const expenseCategories = expenseGroups.flatMap(g => g.categories || []);
     return [expenseGroups, expenseCategories];
-  }, [originalCategoryGroups]);
+  }, [originalCategoryGroups, showToBeBudgeted]);
 
   const [fromCategoryId, setFromCategoryId] = useState<string | null>(null);
   const dispatch = useDispatch();
@@ -67,19 +70,9 @@ export function CoverModal({
   }, [initialMount, onCategoryClick]);
 
   const fromCategory = categories.find(c => c.id === fromCategoryId);
-  const category = categories.find(c => c.id === categoryId);
-
-  if (!category) {
-    return null;
-  }
 
   return (
-    <Modal
-      title={category.name}
-      showHeader
-      focusAfterClose={false}
-      {...modalProps}
-    >
+    <Modal title={title} showHeader focusAfterClose={false} {...modalProps}>
       <View>
         <FieldLabel title="Cover from category:" />
         <TapField value={fromCategory?.name} onClick={onCategoryClick} />
diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
index 1970a1912..a2bba5ee9 100644
--- a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
+++ b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
@@ -31,14 +31,14 @@ export function RolloverBudgetSummaryModal({
     value: 0,
   });
 
-  const openTransferModal = () => {
+  const openTransferAvailableModal = () => {
     dispatch(
       pushModal('transfer', {
         title: 'Transfer: To Budget',
         month,
         amount: sheetValue,
         onSubmit: (amount, toCategoryId) => {
-          onBudgetAction?.(month, 'transfer-available', {
+          onBudgetAction(month, 'transfer-available', {
             amount,
             month,
             category: toCategoryId,
@@ -49,6 +49,22 @@ export function RolloverBudgetSummaryModal({
     );
   };
 
+  const openCoverOverbudgetedModal = () => {
+    dispatch(
+      pushModal('cover', {
+        title: 'Cover: Overbudgeted',
+        month,
+        showToBeBudgeted: false,
+        onSubmit: categoryId => {
+          onBudgetAction(month, 'cover-overbudgeted', {
+            category: categoryId,
+          });
+          dispatch(collapseModals('cover'));
+        },
+      }),
+    );
+  };
+
   const onHoldBuffer = () => {
     dispatch(
       pushModal('hold-buffer', {
@@ -62,7 +78,7 @@ export function RolloverBudgetSummaryModal({
   };
 
   const onResetHoldBuffer = () => {
-    onBudgetAction?.(month, 'reset-hold');
+    onBudgetAction(month, 'reset-hold');
     modalProps.onClose();
   };
 
@@ -70,7 +86,8 @@ export function RolloverBudgetSummaryModal({
     dispatch(
       pushModal('rollover-summary-to-budget-menu', {
         month,
-        onTransfer: openTransferModal,
+        onTransfer: openTransferAvailableModal,
+        onCover: openCoverOverbudgetedModal,
         onResetHoldBuffer,
         onHoldBuffer,
       }),
diff --git a/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx
index da1c28a1e..c94e247be 100644
--- a/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx
+++ b/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx
@@ -14,6 +14,7 @@ type RolloverToBudgetMenuModalProps = ComponentPropsWithoutRef<
 export function RolloverToBudgetMenuModal({
   modalProps,
   onTransfer,
+  onCover,
   onHoldBuffer,
   onResetHoldBuffer,
 }: RolloverToBudgetMenuModalProps) {
@@ -29,6 +30,7 @@ export function RolloverToBudgetMenuModal({
       <ToBudgetMenu
         getItemStyle={() => defaultMenuItemStyle}
         onTransfer={onTransfer}
+        onCover={onCover}
         onHoldBuffer={onHoldBuffer}
         onResetHoldBuffer={onResetHoldBuffer}
       />
diff --git a/packages/loot-core/src/client/actions/queries.ts b/packages/loot-core/src/client/actions/queries.ts
index 692d59682..badb36f37 100644
--- a/packages/loot-core/src/client/actions/queries.ts
+++ b/packages/loot-core/src/client/actions/queries.ts
@@ -53,7 +53,7 @@ export function applyBudgetAction(month, type, args) {
       case 'reset-hold':
         await send('budget/reset-hold', { month });
         break;
-      case 'cover':
+      case 'cover-overspending':
         await send('budget/cover-overspending', {
           month,
           to: args.to,
@@ -67,6 +67,12 @@ export function applyBudgetAction(month, type, args) {
           category: args.category,
         });
         break;
+      case 'cover-overbudgeted':
+        await send('budget/cover-overbudgeted', {
+          month,
+          category: args.category,
+        });
+        break;
       case 'transfer-category':
         await send('budget/transfer-category', {
           month,
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index 4e607993c..941671c32 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -201,6 +201,7 @@ type FinanceModals = {
   'rollover-summary-to-budget-menu': {
     month: string;
     onTransfer: () => void;
+    onCover: () => void;
     onHoldBuffer: () => void;
     onResetHoldBuffer: () => void;
   };
@@ -217,8 +218,9 @@ type FinanceModals = {
     showToBeBudgeted?: boolean;
   };
   cover: {
-    categoryId: string;
+    title: string;
     month: string;
+    showToBeBudgeted?: boolean;
     onSubmit: (fromCategoryId: string) => void;
   };
   'hold-buffer': {
diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts
index 6ddffc06e..2b02a4527 100644
--- a/packages/loot-core/src/server/budget/actions.ts
+++ b/packages/loot-core/src/server/budget/actions.ts
@@ -373,6 +373,20 @@ export async function transferAvailable({
   await setBudget({ category, month, amount: budgeted + amount });
 }
 
+export async function coverOverbudgeted({
+  month,
+  category,
+}: {
+  month: string;
+  category: string;
+}): Promise<void> {
+  const sheetName = monthUtils.sheetForMonth(month);
+  const toBudget = await getSheetValue(sheetName, 'to-budget');
+
+  const categoryBudget = await getSheetValue(sheetName, 'budget-' + category);
+  await setBudget({ category, month, amount: categoryBudget + toBudget });
+}
+
 export async function transferCategory({
   month,
   amount,
diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts
index 7cca22aa5..a1816665d 100644
--- a/packages/loot-core/src/server/budget/app.ts
+++ b/packages/loot-core/src/server/budget/app.ts
@@ -54,6 +54,10 @@ app.method(
   'budget/transfer-available',
   mutator(undoable(actions.transferAvailable)),
 );
+app.method(
+  'budget/cover-overbudgeted',
+  mutator(undoable(actions.coverOverbudgeted)),
+);
 app.method(
   'budget/transfer-category',
   mutator(undoable(actions.transferCategory)),
diff --git a/packages/loot-core/src/server/budget/types/handlers.d.ts b/packages/loot-core/src/server/budget/types/handlers.d.ts
index 66c2f72c7..b5d657436 100644
--- a/packages/loot-core/src/server/budget/types/handlers.d.ts
+++ b/packages/loot-core/src/server/budget/types/handlers.d.ts
@@ -46,6 +46,11 @@ export interface BudgetHandlers {
     category: string;
   }) => Promise<void>;
 
+  'budget/cover-overbudgeted': (arg: {
+    month: string;
+    category: string;
+  }) => Promise<void>;
+
   'budget/transfer-category': (arg: {
     month: string;
     amount: number;
diff --git a/upcoming-release-notes/2850.md b/upcoming-release-notes/2850.md
new file mode 100644
index 000000000..cc1805e68
--- /dev/null
+++ b/upcoming-release-notes/2850.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [joel-jeremy]
+---
+
+Cover overbudgeted action + make balance movement menus only appear on relevant conditions e.g. transfer to another category menu only when there is a leftover balance.
-- 
GitLab