From e6bf6da3814d451a534f5da2138a1881bf79cd66 Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Thu, 19 Sep 2024 16:13:19 -0700
Subject: [PATCH] Undoable auto transfer notes + auto notes for cover (#3411)

* Undo auto transfer notes + auto notes for cover

* Release notes

* Fix notes

* Fix notes undo

* Do not show clicked category on transfer or cover menus

* Fix typecheck error

* typecheck

* Fix removeCategoriesFromGroups
---
 .../desktop-client/src/components/Modals.tsx  |   3 +-
 .../budget/rollover/BalanceMovementMenu.tsx   |  68 +-----------
 .../components/budget/rollover/CoverMenu.tsx  |  55 ++++------
 .../budget/rollover/TransferMenu.tsx          |  40 ++++---
 .../src/components/budget/util.ts             |  22 +++-
 .../components/mobile/budget/BudgetTable.jsx  |   3 +-
 .../src/components/modals/CoverModal.tsx      |  60 ++++-------
 .../src/components/modals/TransferModal.tsx   |  30 ++++--
 .../src/client/state-types/modals.d.ts        |   2 +
 .../loot-core/src/server/budget/actions.ts    | 101 ++++++++++++++++--
 packages/loot-core/src/server/db/index.ts     |  55 ++++++----
 packages/loot-core/src/server/notes/app.ts    |   7 +-
 upcoming-release-notes/3411.md                |   6 ++
 13 files changed, 252 insertions(+), 200 deletions(-)
 create mode 100644 upcoming-release-notes/3411.md

diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index 88aae48cf..7af9f343a 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -497,6 +497,7 @@ export function Modals() {
             <TransferModal
               key={name}
               title={options.title}
+              categoryId={options.categoryId}
               month={options.month}
               amount={options.amount}
               onSubmit={options.onSubmit}
@@ -509,9 +510,9 @@ export function Modals() {
             <CoverModal
               key={name}
               title={options.title}
+              categoryId={options.categoryId}
               month={options.month}
               showToBeBudgeted={options.showToBeBudgeted}
-              category={options.category}
               onSubmit={options.onSubmit}
             />
           );
diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
index 672a25521..02fe80731 100644
--- a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
@@ -1,15 +1,6 @@
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useState } from 'react';
 
-import { runQuery } from 'loot-core/client/query-helpers';
-import { send } from 'loot-core/platform/client/fetch';
-import { q } from 'loot-core/shared/query';
 import { rolloverBudget } from 'loot-core/src/client/queries';
-import * as monthUtils from 'loot-core/src/shared/months';
-import { groupById, integerToCurrency } from 'loot-core/src/shared/util';
-import { type CategoryEntity } from 'loot-core/types/models';
-import { type WithRequired } from 'loot-core/types/util';
-
-import { useCategories } from '../../../hooks/useCategories';
 
 import { BalanceMenu } from './BalanceMenu';
 import { CoverMenu } from './CoverMenu';
@@ -34,8 +25,6 @@ export function BalanceMovementMenu({
   );
   const [menu, setMenu] = useState('menu');
 
-  const { addBudgetTransferNotes } = useBudgetTransferNotes({ month });
-
   return (
     <>
       {menu === 'menu' && (
@@ -55,6 +44,7 @@ export function BalanceMovementMenu({
 
       {menu === 'transfer' && (
         <TransferMenu
+          categoryId={categoryId}
           initialAmount={catBalance}
           showToBeBudgeted={true}
           onClose={onClose}
@@ -64,18 +54,13 @@ export function BalanceMovementMenu({
               from: categoryId,
               to: toCategoryId,
             });
-            addBudgetTransferNotes({
-              fromCategoryId: categoryId,
-              toCategoryId,
-              amount,
-            });
           }}
         />
       )}
 
       {menu === 'cover' && (
         <CoverMenu
-          category={categoryId}
+          categoryId={categoryId}
           onClose={onClose}
           onSubmit={fromCategoryId => {
             onBudgetAction(month, 'cover-overspending', {
@@ -88,50 +73,3 @@ export function BalanceMovementMenu({
     </>
   );
 }
-
-const useBudgetTransferNotes = ({ month }: { month: string }) => {
-  const { list: categories } = useCategories();
-  const categoriesById = useMemo(() => {
-    return groupById(categories as WithRequired<CategoryEntity, 'id'>[]);
-  }, [categories]);
-
-  const getNotes = async (id: string) => {
-    const { data: notes } = await runQuery(
-      q('notes').filter({ id }).select('note'),
-    );
-    return (notes && notes[0]?.note) ?? '';
-  };
-
-  const addNewLine = (notes?: string) => `${notes}${notes && '\n'}`;
-
-  const addBudgetTransferNotes = useCallback(
-    async ({
-      fromCategoryId,
-      toCategoryId,
-      amount,
-    }: {
-      fromCategoryId: Required<CategoryEntity['id']>;
-      toCategoryId: Required<CategoryEntity['id']>;
-      amount: number;
-    }) => {
-      const displayAmount = integerToCurrency(amount);
-
-      const monthBudgetNotesId = `budget-${month}`;
-      const existingMonthBudgetNotes = addNewLine(
-        await getNotes(monthBudgetNotesId),
-      );
-
-      const displayDay = monthUtils.format(monthUtils.currentDate(), 'MMMM dd');
-      const fromCategoryName = categoriesById[fromCategoryId || ''].name;
-      const toCategoryName = categoriesById[toCategoryId || ''].name;
-
-      await send('notes-save', {
-        id: monthBudgetNotesId,
-        note: `${existingMonthBudgetNotes}- Reassigned ${displayAmount} from ${fromCategoryName} to ${toCategoryName} on ${displayDay}`,
-      });
-    },
-    [categoriesById, month],
-  );
-
-  return { addBudgetTransferNotes };
-};
diff --git a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx
index 1fec28b1e..6a126b389 100644
--- a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx
@@ -1,61 +1,44 @@
 import React, { useMemo, useState } from 'react';
 
-import {
-  type CategoryGroupEntity,
-  type CategoryEntity,
-} from 'loot-core/src/types/models';
+import { type CategoryEntity } from 'loot-core/src/types/models';
 
 import { useCategories } from '../../../hooks/useCategories';
 import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
 import { Button } from '../../common/Button2';
 import { InitialFocus } from '../../common/InitialFocus';
 import { View } from '../../common/View';
-import { addToBeBudgetedGroup } from '../util';
-
-function removeSelectedCategory(
-  categoryGroups: CategoryGroupEntity[],
-  category?: CategoryEntity['id'],
-) {
-  if (!category) return categoryGroups;
-
-  return categoryGroups
-    .map(group => ({
-      ...group,
-      categories: group.categories?.filter(cat => cat.id !== category),
-    }))
-    .filter(group => group.categories?.length);
-}
+import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
 
 type CoverMenuProps = {
   showToBeBudgeted?: boolean;
-  category?: CategoryEntity['id'];
-  onSubmit: (categoryId: string) => void;
+  categoryId?: CategoryEntity['id'];
+  onSubmit: (categoryId: CategoryEntity['id']) => void;
   onClose: () => void;
 };
 
 export function CoverMenu({
   showToBeBudgeted = true,
-  category,
+  categoryId,
   onSubmit,
   onClose,
 }: CoverMenuProps) {
   const { grouped: originalCategoryGroups } = useCategories();
-  const expenseGroups = originalCategoryGroups.filter(g => !g.is_income);
 
-  const categoryGroups = showToBeBudgeted
-    ? addToBeBudgetedGroup(expenseGroups)
-    : expenseGroups;
+  const [fromCategoryId, setFromCategoryId] = useState<string | null>(null);
 
-  const [categoryId, setCategoryId] = useState<string | null>(null);
-
-  const filteredCategoryGroups = useMemo(
-    () => removeSelectedCategory(categoryGroups, category),
-    [categoryGroups, category],
-  );
+  const filteredCategoryGroups = useMemo(() => {
+    const expenseGroups = originalCategoryGroups.filter(g => !g.is_income);
+    const categoryGroups = showToBeBudgeted
+      ? addToBeBudgetedGroup(expenseGroups)
+      : expenseGroups;
+    return categoryId
+      ? removeCategoriesFromGroups(categoryGroups, categoryId)
+      : categoryGroups;
+  }, [categoryId, showToBeBudgeted, originalCategoryGroups]);
 
   function submit() {
-    if (categoryId) {
-      onSubmit(categoryId);
+    if (fromCategoryId) {
+      onSubmit(fromCategoryId);
     }
     onClose();
   }
@@ -67,9 +50,9 @@ export function CoverMenu({
         {node => (
           <CategoryAutocomplete
             categoryGroups={filteredCategoryGroups}
-            value={categoryGroups.find(g => g.id === categoryId) ?? null}
+            value={null}
             openOnFocus={true}
-            onSelect={(id: string | undefined) => setCategoryId(id || null)}
+            onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
             inputProps={{
               inputRef: node,
               onEnter: event => !event.defaultPrevented && submit(),
diff --git a/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx b/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx
index 76df5c655..f304eab3d 100644
--- a/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx
@@ -1,7 +1,8 @@
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
 
 import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
 import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
+import { type CategoryEntity } from 'loot-core/types/models';
 
 import { useCategories } from '../../../hooks/useCategories';
 import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
@@ -9,32 +10,39 @@ import { Button } from '../../common/Button2';
 import { InitialFocus } from '../../common/InitialFocus';
 import { Input } from '../../common/Input';
 import { View } from '../../common/View';
-import { addToBeBudgetedGroup } from '../util';
+import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
 
 type TransferMenuProps = {
+  categoryId?: CategoryEntity['id'];
   initialAmount?: number;
   showToBeBudgeted?: boolean;
-  onSubmit: (amount: number, categoryId: string) => void;
+  onSubmit: (amount: number, categoryId: CategoryEntity['id']) => void;
   onClose: () => void;
 };
 
 export function TransferMenu({
+  categoryId,
   initialAmount = 0,
   showToBeBudgeted,
   onSubmit,
   onClose,
 }: TransferMenuProps) {
   const { grouped: originalCategoryGroups } = useCategories();
-  const filteredCategoryGroups = originalCategoryGroups.filter(
-    g => !g.is_income,
-  );
-  const categoryGroups = showToBeBudgeted
-    ? addToBeBudgetedGroup(filteredCategoryGroups)
-    : filteredCategoryGroups;
+  const filteredCategoryGroups = useMemo(() => {
+    const expenseCategoryGroups = originalCategoryGroups.filter(
+      g => !g.is_income,
+    );
+    const categoryGroups = showToBeBudgeted
+      ? addToBeBudgetedGroup(expenseCategoryGroups)
+      : expenseCategoryGroups;
+    return categoryId
+      ? removeCategoriesFromGroups(categoryGroups, categoryId)
+      : categoryGroups;
+  }, [originalCategoryGroups, categoryId, showToBeBudgeted]);
 
   const _initialAmount = integerToCurrency(Math.max(initialAmount, 0));
   const [amount, setAmount] = useState<string | null>(null);
-  const [categoryId, setCategoryId] = useState<string | null>(null);
+  const [toCategoryId, setToCategoryId] = useState<string | null>(null);
 
   const _onSubmit = (newAmount: string | null, categoryId: string | null) => {
     const parsedAmount = evalArithmetic(newAmount || '');
@@ -53,20 +61,20 @@ export function TransferMenu({
           <Input
             defaultValue={_initialAmount}
             onUpdate={value => setAmount(value)}
-            onEnter={() => _onSubmit(amount, categoryId)}
+            onEnter={() => _onSubmit(amount, toCategoryId)}
           />
         </InitialFocus>
       </View>
       <View style={{ margin: '10px 0 5px 0' }}>To:</View>
 
       <CategoryAutocomplete
-        categoryGroups={categoryGroups}
-        value={categoryGroups.find(g => g.id === categoryId) ?? null}
+        categoryGroups={filteredCategoryGroups}
+        value={null}
         openOnFocus={true}
-        onSelect={(id: string | undefined) => setCategoryId(id || null)}
+        onSelect={(id: string | undefined) => setToCategoryId(id || null)}
         inputProps={{
           onEnter: event =>
-            !event.defaultPrevented && _onSubmit(amount, categoryId),
+            !event.defaultPrevented && _onSubmit(amount, toCategoryId),
           placeholder: '(none)',
         }}
         showHiddenCategories={true}
@@ -85,7 +93,7 @@ export function TransferMenu({
             paddingTop: 3,
             paddingBottom: 3,
           }}
-          onPress={() => _onSubmit(amount, categoryId)}
+          onPress={() => _onSubmit(amount, toCategoryId)}
         >
           Transfer
         </Button>
diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts
index c8f7c6185..31472c980 100644
--- a/packages/desktop-client/src/components/budget/util.ts
+++ b/packages/desktop-client/src/components/budget/util.ts
@@ -3,7 +3,10 @@ import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
 import { send } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { type Handlers } from 'loot-core/src/types/handlers';
-import { type CategoryGroupEntity } from 'loot-core/src/types/models';
+import {
+  type CategoryEntity,
+  type CategoryGroupEntity,
+} from 'loot-core/src/types/models';
 import { type SyncedPrefs } from 'loot-core/src/types/prefs';
 
 import { type CSSProperties, styles, theme } from '../../style';
@@ -32,6 +35,23 @@ export function addToBeBudgetedGroup(groups: CategoryGroupEntity[]) {
   ];
 }
 
+export function removeCategoriesFromGroups(
+  categoryGroups: CategoryGroupEntity[],
+  ...categoryIds: CategoryEntity['id'][]
+) {
+  if (!categoryIds || categoryIds.length === 0) return categoryGroups;
+
+  const categoryIdsSet = new Set(categoryIds);
+
+  return categoryGroups
+    .map(group => ({
+      ...group,
+      categories:
+        group.categories?.filter(cat => !categoryIdsSet.has(cat.id)) ?? [],
+    }))
+    .filter(group => group.categories?.length);
+}
+
 export function separateGroups(categoryGroups: CategoryGroupEntity[]) {
   return [
     categoryGroups.filter(g => !g.is_income),
diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
index 0bee9e2d0..c9f1ad4b2 100644
--- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
+++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
@@ -420,6 +420,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
     dispatch(
       pushModal('transfer', {
         title: category.name,
+        categoryId: category.id,
         month,
         amount: catBalance,
         onSubmit: (amount, toCategoryId) => {
@@ -453,7 +454,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
       pushModal('cover', {
         title: category.name,
         month,
-        category: category.id,
+        categoryId: category.id,
         onSubmit: fromCategoryId => {
           onBudgetAction(month, 'cover-overspending', {
             to: category.id,
diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx
index a604fae23..49d758bb3 100644
--- a/packages/desktop-client/src/components/modals/CoverModal.tsx
+++ b/packages/desktop-client/src/components/modals/CoverModal.tsx
@@ -2,66 +2,48 @@ import React, { useCallback, useMemo, useState } from 'react';
 import { useDispatch } from 'react-redux';
 
 import { pushModal } from 'loot-core/client/actions';
-import {
-  type CategoryGroupEntity,
-  type CategoryEntity,
-} from 'loot-core/src/types/models';
+import { type CategoryEntity } from 'loot-core/src/types/models';
 
 import { useCategories } from '../../hooks/useCategories';
 import { styles } from '../../style';
-import { addToBeBudgetedGroup } from '../budget/util';
+import {
+  addToBeBudgetedGroup,
+  removeCategoriesFromGroups,
+} from '../budget/util';
 import { Button } from '../common/Button2';
 import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
 import { View } from '../common/View';
 import { FieldLabel, TapField } from '../mobile/MobileForms';
 
-function removeSelectedCategory(
-  categoryGroups: CategoryGroupEntity[],
-  category?: CategoryEntity['id'],
-) {
-  if (!category) return categoryGroups;
-
-  return categoryGroups
-    .map(group => ({
-      ...group,
-      categories: group.categories?.filter(cat => cat.id !== category),
-    }))
-    .filter(group => group.categories?.length);
-}
-
 type CoverModalProps = {
   title: string;
+  categoryId?: CategoryEntity['id'];
   month: string;
   showToBeBudgeted?: boolean;
-  category?: CategoryEntity['id'];
-  onSubmit: (categoryId: string) => void;
+  onSubmit: (categoryId: CategoryEntity['id']) => void;
 };
 
 export function CoverModal({
   title,
+  categoryId,
   month,
   showToBeBudgeted = true,
-  category,
   onSubmit,
 }: CoverModalProps) {
   const { grouped: originalCategoryGroups } = useCategories();
   const [categoryGroups, categories] = useMemo(() => {
-    const filteredCategoryGroups = originalCategoryGroups.filter(
-      g => !g.is_income,
+    const expenseGroups = originalCategoryGroups.filter(g => !g.is_income);
+    const categoryGroups = showToBeBudgeted
+      ? addToBeBudgetedGroup(expenseGroups)
+      : expenseGroups;
+    const filteredCategoryGroups = categoryId
+      ? removeCategoriesFromGroups(categoryGroups, categoryId)
+      : categoryGroups;
+    const filteredCategoryies = filteredCategoryGroups.flatMap(
+      g => g.categories || [],
     );
-
-    const expenseGroups = showToBeBudgeted
-      ? addToBeBudgetedGroup(filteredCategoryGroups)
-      : filteredCategoryGroups;
-
-    const expenseCategories = expenseGroups.flatMap(g => g.categories || []);
-    return [expenseGroups, expenseCategories];
-  }, [originalCategoryGroups, showToBeBudgeted]);
-
-  const filteredCategoryGroups = useMemo(
-    () => removeSelectedCategory(categoryGroups, category),
-    [categoryGroups, category],
-  );
+    return [filteredCategoryGroups, filteredCategoryies];
+  }, [categoryId, originalCategoryGroups, showToBeBudgeted]);
 
   const [fromCategoryId, setFromCategoryId] = useState<string | null>(null);
   const dispatch = useDispatch();
@@ -69,14 +51,14 @@ export function CoverModal({
   const onCategoryClick = useCallback(() => {
     dispatch(
       pushModal('category-autocomplete', {
-        categoryGroups: filteredCategoryGroups,
+        categoryGroups,
         month,
         onSelect: categoryId => {
           setFromCategoryId(categoryId);
         },
       }),
     );
-  }, [filteredCategoryGroups, dispatch, month]);
+  }, [categoryGroups, dispatch, month]);
 
   const _onSubmit = (categoryId: string | null) => {
     if (categoryId) {
diff --git a/packages/desktop-client/src/components/modals/TransferModal.tsx b/packages/desktop-client/src/components/modals/TransferModal.tsx
index 190d24001..18656bc8e 100644
--- a/packages/desktop-client/src/components/modals/TransferModal.tsx
+++ b/packages/desktop-client/src/components/modals/TransferModal.tsx
@@ -2,10 +2,14 @@ import React, { useMemo, useState } from 'react';
 import { useDispatch } from 'react-redux';
 
 import { pushModal } from 'loot-core/client/actions';
+import { type CategoryEntity } from 'loot-core/types/models';
 
 import { useCategories } from '../../hooks/useCategories';
 import { styles } from '../../style';
-import { addToBeBudgetedGroup } from '../budget/util';
+import {
+  addToBeBudgetedGroup,
+  removeCategoriesFromGroups,
+} from '../budget/util';
 import { Button } from '../common/Button2';
 import { InitialFocus } from '../common/InitialFocus';
 import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
@@ -15,14 +19,16 @@ import { AmountInput } from '../util/AmountInput';
 
 type TransferModalProps = {
   title: string;
+  categoryId?: CategoryEntity['id'];
   month: string;
   amount: number;
   showToBeBudgeted: boolean;
-  onSubmit: (amount: number, toCategoryId: string) => void;
+  onSubmit: (amount: number, toCategoryId: CategoryEntity['id']) => void;
 };
 
 export function TransferModal({
   title,
+  categoryId,
   month,
   amount: initialAmount,
   showToBeBudgeted,
@@ -30,15 +36,19 @@ export function TransferModal({
 }: TransferModalProps) {
   const { grouped: originalCategoryGroups } = useCategories();
   const [categoryGroups, categories] = useMemo(() => {
-    const filteredCategoryGroups = originalCategoryGroups.filter(
-      g => !g.is_income,
+    const expenseGroups = originalCategoryGroups.filter(g => !g.is_income);
+    const categoryGroups = showToBeBudgeted
+      ? addToBeBudgetedGroup(expenseGroups)
+      : expenseGroups;
+
+    const filteredCategoryGroups = categoryId
+      ? removeCategoriesFromGroups(categoryGroups, categoryId)
+      : categoryGroups;
+    const filteredCategories = filteredCategoryGroups.flatMap(
+      g => g.categories || [],
     );
-    const expenseGroups = showToBeBudgeted
-      ? addToBeBudgetedGroup(filteredCategoryGroups)
-      : filteredCategoryGroups;
-    const expenseCategories = expenseGroups.flatMap(g => g.categories || []);
-    return [expenseGroups, expenseCategories];
-  }, [originalCategoryGroups, showToBeBudgeted]);
+    return [filteredCategoryGroups, filteredCategories];
+  }, [categoryId, originalCategoryGroups, showToBeBudgeted]);
 
   const [amount, setAmount] = useState<number>(0);
   const [toCategoryId, setToCategoryId] = useState<string | null>(null);
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 1b7870a5a..ef8c148e4 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -225,6 +225,7 @@ type FinanceModals = {
   };
   transfer: {
     title: string;
+    categoryId?: CategoryEntity['id'];
     month: string;
     amount: number;
     onSubmit: (amount: number, toCategoryId: string) => void;
@@ -232,6 +233,7 @@ type FinanceModals = {
   };
   cover: {
     title: string;
+    categoryId?: CategoryEntity['id'];
     month: string;
     showToBeBudgeted?: boolean;
     onSubmit: (fromCategoryId: string) => void;
diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts
index 28b803e72..0d9950553 100644
--- a/packages/loot-core/src/server/budget/actions.ts
+++ b/packages/loot-core/src/server/budget/actions.ts
@@ -1,6 +1,6 @@
 // @ts-strict-ignore
 import * as monthUtils from '../../shared/months';
-import { safeNumber } from '../../shared/util';
+import { integerToCurrency, safeNumber } from '../../shared/util';
 import * as db from '../db';
 import * as sheet from '../sheet';
 import { batchMessages } from '../sync';
@@ -371,7 +371,20 @@ export async function coverOverspending({
     });
   }
 
-  await setBudget({ category: to, month, amount: toBudgeted + amountCovered });
+  await batchMessages(async () => {
+    await setBudget({
+      category: to,
+      month,
+      amount: toBudgeted + amountCovered,
+    });
+
+    await addMovementNotes({
+      month,
+      amount: amountCovered,
+      to,
+      from,
+    });
+  });
 }
 
 export async function transferAvailable({
@@ -402,7 +415,17 @@ export async function coverOverbudgeted({
   const toBudget = await getSheetValue(sheetName, 'to-budget');
 
   const categoryBudget = await getSheetValue(sheetName, 'budget-' + category);
-  await setBudget({ category, month, amount: categoryBudget + toBudget });
+
+  await batchMessages(async () => {
+    await setBudget({ category, month, amount: categoryBudget + toBudget });
+
+    await addMovementNotes({
+      month,
+      amount: -toBudget,
+      from: category,
+      to: 'overbudgeted',
+    });
+  });
 }
 
 export async function transferCategory({
@@ -419,14 +442,23 @@ export async function transferCategory({
   const sheetName = monthUtils.sheetForMonth(month);
   const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
 
-  await setBudget({ category: from, month, amount: fromBudgeted - amount });
+  await batchMessages(async () => {
+    await setBudget({ category: from, month, amount: fromBudgeted - amount });
 
-  // If we are simply moving it back into available cash to budget,
-  // don't do anything else
-  if (to !== 'to-be-budgeted') {
-    const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
-    await setBudget({ category: to, month, amount: toBudgeted + amount });
-  }
+    // If we are simply moving it back into available cash to budget,
+    // don't do anything else
+    if (to !== 'to-be-budgeted') {
+      const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
+      await setBudget({ category: to, month, amount: toBudgeted + amount });
+    }
+
+    await addMovementNotes({
+      month,
+      amount,
+      to,
+      from,
+    });
+  });
 }
 
 export async function setCategoryCarryover({
@@ -447,3 +479,52 @@ export async function setCategoryCarryover({
     }
   });
 }
+
+function addNewLine(notes?: string) {
+  return !notes ? '' : `${notes}${notes && '\n'}`;
+}
+
+async function addMovementNotes({
+  month,
+  amount,
+  to,
+  from,
+}: {
+  month: string;
+  amount: number;
+  to: 'to-be-budgeted' | 'overbudgeted' | string;
+  from: 'to-be-budgeted' | string;
+}) {
+  const displayAmount = integerToCurrency(amount);
+
+  const monthBudgetNotesId = `budget-${month}`;
+  const existingMonthBudgetNotes = addNewLine(
+    db.firstSync(`SELECT n.note FROM notes n WHERE n.id = ?`, [
+      monthBudgetNotesId,
+    ])?.note,
+  );
+
+  const displayDay = monthUtils.format(monthUtils.currentDate(), 'MMMM dd');
+  const categories = await db.getCategories(
+    [from, to].filter(c => c !== 'to-be-budgeted' && c !== 'overbudgeted'),
+  );
+
+  const fromCategoryName =
+    from === 'to-be-budgeted'
+      ? 'To Budget'
+      : categories.find(c => c.id === from)?.name;
+
+  const toCategoryName =
+    to === 'to-be-budgeted'
+      ? 'To Budget'
+      : to === 'overbudgeted'
+        ? 'Overbudgeted'
+        : categories.find(c => c.id === to)?.name;
+
+  const note = `Reassigned ${displayAmount} from ${fromCategoryName} → ${toCategoryName} on ${displayDay}`;
+
+  await db.update('notes', {
+    id: monthBudgetNotesId,
+    note: `${existingMonthBudgetNotes}- ${note}`,
+  });
+}
diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts
index 04f49ee36..412f15270 100644
--- a/packages/loot-core/src/server/db/index.ts
+++ b/packages/loot-core/src/server/db/index.ts
@@ -282,23 +282,36 @@ export function updateWithSchema(table, fields) {
 // Data-specific functions. Ideally this would be split up into
 // different files
 
-export async function getCategories(): Promise<CategoryEntity[]> {
-  return await all(`
-    SELECT c.* FROM categories c WHERE c.tombstone = 0
-      ORDER BY c.sort_order, c.id
-  `);
-}
-
-export async function getCategoriesGrouped(): Promise<
-  Array<CategoryGroupEntity>
-> {
-  const groups = await all(`
-    SELECT cg.* FROM category_groups cg WHERE cg.tombstone = 0 ORDER BY cg.is_income, cg.sort_order, cg.id
-  `);
-  const categories = await all(`
-    SELECT c.* FROM categories c WHERE c.tombstone = 0
-      ORDER BY c.sort_order, c.id
-  `);
+export async function getCategories(
+  ids?: Array<CategoryEntity['id']>,
+): Promise<CategoryEntity[]> {
+  const whereIn = ids ? `c.id IN (${toSqlQueryParameters(ids)}) AND` : '';
+  const query = `SELECT c.* FROM categories c WHERE ${whereIn} c.tombstone = 0 ORDER BY c.sort_order, c.id`;
+  return ids ? await all(query, [...ids]) : await all(query);
+}
+
+export async function getCategoriesGrouped(
+  ids?: Array<CategoryGroupEntity['id']>,
+): Promise<Array<CategoryGroupEntity>> {
+  const categoryGroupWhereIn = ids
+    ? `cg.id IN (${toSqlQueryParameters(ids)}) AND`
+    : '';
+  const categoryGroupQuery = `SELECT cg.* FROM category_groups cg WHERE ${categoryGroupWhereIn} cg.tombstone = 0
+    ORDER BY cg.is_income, cg.sort_order, cg.id`;
+
+  const categoryWhereIn = ids
+    ? `c.cat_group IN (${toSqlQueryParameters(ids)}) AND`
+    : '';
+  const categoryQuery = `SELECT c.* FROM categories c WHERE ${categoryWhereIn} c.tombstone = 0
+    ORDER BY c.sort_order, c.id`;
+
+  const groups = ids
+    ? await all(categoryGroupQuery, [...ids])
+    : await all(categoryGroupQuery);
+
+  const categories = ids
+    ? await all(categoryQuery, [...ids])
+    : await all(categoryQuery);
 
   return groups.map(group => {
     return {
@@ -553,7 +566,7 @@ export function getCommonPayees() {
   return all(`
     SELECT     p.id as id, p.name as name, p.favorite as favorite,
       p.category as category, TRUE as common, NULL as transfer_acct,
-    count(*) as c, 
+    count(*) as c,
     max(t.date) as latest
     FROM payees p
     LEFT JOIN v_transactions_internal_alive t on t.payee == p.id
@@ -561,7 +574,7 @@ export function getCommonPayees() {
     AND p.tombstone = 0
     AND t.date > ${twelveWeeksAgo}
     GROUP BY p.id
-    ORDER BY c DESC ,p.transfer_acct IS NULL DESC, p.name 
+    ORDER BY c DESC ,p.transfer_acct IS NULL DESC, p.name
     COLLATE NOCASE
     LIMIT ${limit}
   `);
@@ -681,3 +694,7 @@ export function updateTransaction(transaction) {
 export async function deleteTransaction(transaction) {
   return delete_('transactions', transaction.id);
 }
+
+function toSqlQueryParameters(params: unknown[]) {
+  return params.map(() => '?').join(',');
+}
diff --git a/packages/loot-core/src/server/notes/app.ts b/packages/loot-core/src/server/notes/app.ts
index 466ac86e1..aac00a662 100644
--- a/packages/loot-core/src/server/notes/app.ts
+++ b/packages/loot-core/src/server/notes/app.ts
@@ -1,3 +1,4 @@
+import { NoteEntity } from '../../types/models';
 import { createApp } from '../app';
 import * as db from '../db';
 
@@ -5,6 +6,8 @@ import { NotesHandlers } from './types/handlers';
 
 export const app = createApp<NotesHandlers>();
 
-app.method('notes-save', async ({ id, note }) => {
+async function updateNotes({ id, note }: NoteEntity) {
   await db.update('notes', { id, note });
-});
+}
+
+app.method('notes-save', updateNotes);
diff --git a/upcoming-release-notes/3411.md b/upcoming-release-notes/3411.md
new file mode 100644
index 000000000..ca639b898
--- /dev/null
+++ b/upcoming-release-notes/3411.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [joel-jeremy]
+---
+
+Undoable auto tranfer notes + auto notes for cover
-- 
GitLab