diff --git a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx
index c6a2d3d720701ed54c9c8b6ced8c014fb77e8db7..cb33597e0ffa58b365d69f2ff3c9030c64682913 100644
--- a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx
@@ -402,6 +402,10 @@ export function BudgetSummary({
                         name: 'overwrite-goal-template',
                         text: 'Overwrite with budget template',
                       },
+                      isGoalTemplatesEnabled && {
+                        name: 'cleanup-goal-template',
+                        text: 'End of month cleanup',
+                      },
                     ]}
                   />
                 </Tooltip>
diff --git a/packages/loot-core/src/client/actions/queries.ts b/packages/loot-core/src/client/actions/queries.ts
index d324a627f65e7d2478fe0dba84a5ba8b8ae97ea7..70ab734faec2df65a8ee218bb44dd189da7ce8c1 100644
--- a/packages/loot-core/src/client/actions/queries.ts
+++ b/packages/loot-core/src/client/actions/queries.ts
@@ -37,6 +37,13 @@ export function applyBudgetAction(month, type, args) {
           ),
         );
         break;
+      case 'cleanup-goal-template':
+        dispatch(
+          addNotification(
+            await send('budget/cleanup-goal-template', { month }),
+          ),
+        );
+        break;
       case 'hold':
         await send('budget/hold-for-next-month', {
           month,
diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts
index bf626da569c9ccb55c8a63d75d74f42f05fe407f..a501281eed0b2f8bd196fa360a85184a17300f2e 100644
--- a/packages/loot-core/src/server/budget/app.ts
+++ b/packages/loot-core/src/server/budget/app.ts
@@ -3,6 +3,7 @@ import { mutator } from '../mutators';
 import { undoable } from '../undo';
 
 import * as actions from './actions';
+import * as cleanupActions from './cleanup-template';
 import * as goalActions from './goaltemplates';
 
 let app = createApp();
@@ -22,6 +23,10 @@ app.method(
   'budget/overwrite-goal-template',
   mutator(undoable(goalActions.overwriteTemplate)),
 );
+app.method(
+  'budget/cleanup-goal-template',
+  mutator(undoable(cleanupActions.cleanupTemplate)),
+);
 app.method(
   'budget/hold-for-next-month',
   mutator(undoable(actions.holdForNextMonth)),
diff --git a/packages/loot-core/src/server/budget/cleanup-template.pegjs b/packages/loot-core/src/server/budget/cleanup-template.pegjs
new file mode 100644
index 0000000000000000000000000000000000000000..ba958eaab2c369ccda52f0e42c6ba7fa9220c7de
--- /dev/null
+++ b/packages/loot-core/src/server/budget/cleanup-template.pegjs
@@ -0,0 +1,15 @@
+// https://peggyjs.org
+
+expr
+  = source
+    { return { type: 'source' } }
+  / sink _? weight: weight?
+    { return { type: 'sink', weight: +weight || 1 } }
+
+source = 'source'
+sink = 'sink'
+
+_ 'space' = ' '+
+d 'digit' = [0-9]
+
+weight 'weight' = weight: $(d+) { return +weight }
\ No newline at end of file
diff --git a/packages/loot-core/src/server/budget/cleanup-template.ts b/packages/loot-core/src/server/budget/cleanup-template.ts
new file mode 100644
index 0000000000000000000000000000000000000000..356546b20788fe7b5584ce39de7add91c0272213
--- /dev/null
+++ b/packages/loot-core/src/server/budget/cleanup-template.ts
@@ -0,0 +1,160 @@
+import * as monthUtils from '../../shared/months';
+import * as db from '../db';
+
+import { setBudget, getSheetValue } from './actions';
+import { parse } from './cleanup-template.pegjs';
+
+export function cleanupTemplate({ month }) {
+  return processCleanup(month);
+}
+
+async function processCleanup(month) {
+  let num_sources = 0;
+  let num_sinks = 0;
+  let total_weight = 0;
+  let errors = [];
+  let sinkCategory = [];
+
+  let category_templates = await getCategoryTemplates();
+  let categories = await db.all(
+    'SELECT * FROM v_categories WHERE tombstone = 0',
+  );
+  let sheetName = monthUtils.sheetForMonth(month);
+  for (let c = 0; c < categories.length; c++) {
+    let category = categories[c];
+    let template = category_templates[category.id];
+    if (template) {
+      if (template.filter(t => t.type === 'source').length > 0) {
+        let balance = await getSheetValue(sheetName, `leftover-${category.id}`);
+        let budgeted = await getSheetValue(sheetName, `budget-${category.id}`);
+        await setBudget({
+          category: category.id,
+          month,
+          amount: budgeted - balance,
+        });
+        num_sources += 1;
+      }
+      if (template.filter(t => t.type === 'sink').length > 0) {
+        sinkCategory.push({ cat: category, temp: template });
+        num_sinks += 1;
+        total_weight += template[0].weight;
+      }
+    }
+  }
+
+  //funds all underfunded categories first unless the overspending rollover is checked
+  let db_month = parseInt(month.replace('-', ''));
+  for (let c = 0; c < categories.length; c++) {
+    let category = categories[c];
+    let budgetAvailable = await getSheetValue(sheetName, `to-budget`);
+    let balance = await getSheetValue(sheetName, `leftover-${category.id}`);
+    let budgeted = await getSheetValue(sheetName, `budget-${category.id}`);
+    let to_budget = budgeted + Math.abs(balance);
+    let categoryId = category.id;
+    let carryover = await db.first(
+      `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`,
+      [db_month, categoryId],
+    );
+
+    if (
+      balance < 0 &&
+      Math.abs(balance) <= budgetAvailable &&
+      !category.is_income &&
+      carryover.carryover === 0
+    ) {
+      await setBudget({
+        category: category.id,
+        month,
+        amount: to_budget,
+      });
+    }
+  }
+
+  let budgetAvailable = await getSheetValue(sheetName, `to-budget`);
+
+  if (budgetAvailable <= 0) {
+    errors.push('No funds are available to reallocate.');
+  }
+
+  for (let c = 0; c < sinkCategory.length; c++) {
+    let budgeted = await getSheetValue(
+      sheetName,
+      `budget-${sinkCategory[c].cat.id}`,
+    );
+    let categoryId = sinkCategory[c].cat.id;
+    let to_budget =
+      budgeted +
+      Math.round(
+        (sinkCategory[c].temp[0].weight / total_weight) * budgetAvailable,
+      );
+    if (c === sinkCategory.length - 1) {
+      let currentBudgetAvailable = await getSheetValue(sheetName, `to-budget`);
+      if (to_budget > currentBudgetAvailable) {
+        to_budget = budgeted + currentBudgetAvailable;
+      }
+    }
+    await setBudget({
+      category: categoryId,
+      month,
+      amount: to_budget,
+    });
+  }
+
+  if (num_sources === 0) {
+    if (errors.length) {
+      return {
+        type: 'error',
+        sticky: true,
+        message: `There were errors interpreting some templates:`,
+        pre: errors.join('\n\n'),
+      };
+    } else {
+      return { type: 'message', message: 'All categories were up to date.' };
+    }
+  } else {
+    let applied = `Successfully returned funds from ${num_sources} ${
+      num_sources === 1 ? 'source' : 'sources'
+    } and funded ${num_sinks} sinking ${num_sinks === 1 ? 'fund' : 'funds'}.`;
+    if (errors.length) {
+      return {
+        sticky: true,
+        message: `${applied} There were errors interpreting some templates:`,
+        pre: errors.join('\n\n'),
+      };
+    } else {
+      return {
+        type: 'message',
+        message: applied,
+      };
+    }
+  }
+}
+
+const TEMPLATE_PREFIX = '#cleanup ';
+async function getCategoryTemplates() {
+  let templates = {};
+
+  let notes = await db.all(
+    `SELECT * FROM notes WHERE lower(note) like '%${TEMPLATE_PREFIX}%'`,
+  );
+
+  for (let n = 0; n < notes.length; n++) {
+    let lines = notes[n].note.split('\n');
+    let template_lines = [];
+    for (let l = 0; l < lines.length; l++) {
+      let line = lines[l].trim();
+      if (!line.toLowerCase().startsWith(TEMPLATE_PREFIX)) continue;
+      let expression = line.slice(TEMPLATE_PREFIX.length);
+      try {
+        let parsed = parse(expression);
+        template_lines.push(parsed);
+      } catch (e) {
+        template_lines.push({ type: 'error', line, error: e });
+      }
+    }
+    if (template_lines.length) {
+      templates[notes[n].id] = template_lines;
+    }
+  }
+  return templates;
+}
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 c41294e4f60f7b75f30bdd401ef4b890ddb6d6dc..eb251efbd897e65c10916535eb5535c8d2247dd6 100644
--- a/packages/loot-core/src/server/budget/types/handlers.d.ts
+++ b/packages/loot-core/src/server/budget/types/handlers.d.ts
@@ -11,6 +11,8 @@ export interface BudgetHandlers {
 
   'budget/overwrite-goal-template': (...args: unknown[]) => Promise<unknown>;
 
+  'budget/cleanup-goal-template': (...args: unknown[]) => Promise<unknown>;
+
   'budget/hold-for-next-month': (...args: unknown[]) => Promise<unknown>;
 
   'budget/reset-hold': (...args: unknown[]) => Promise<unknown>;
diff --git a/upcoming-release-notes/1016.md b/upcoming-release-notes/1016.md
new file mode 100644
index 0000000000000000000000000000000000000000..01b0ee1134794d0944f895f5345e8496e966cf9e
--- /dev/null
+++ b/upcoming-release-notes/1016.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [shall0pass]
+---
+
+Add menu item and keywords for end-of-month budget reassignments