diff --git a/packages/desktop-client/e2e/accounts.test.js b/packages/desktop-client/e2e/accounts.test.js
index 45ca7096a28f030992e0639f150c6a6f28bcbce9..93029854ef8faaef302d7532c928c272344d4986 100644
--- a/packages/desktop-client/e2e/accounts.test.js
+++ b/packages/desktop-client/e2e/accounts.test.js
@@ -7,6 +7,7 @@ test.describe('Accounts', () => {
   let page;
   let navigation;
   let configurationPage;
+  let accountPage;
 
   test.beforeAll(async ({ browser }) => {
     page = await browser.newPage();
@@ -22,7 +23,7 @@ test.describe('Accounts', () => {
   });
 
   test('creates a new account and views the initial balance transaction', async () => {
-    const accountPage = await navigation.createAccount({
+    accountPage = await navigation.createAccount({
       name: 'New Account',
       offBudget: false,
       balance: 100,
@@ -38,7 +39,7 @@ test.describe('Accounts', () => {
   });
 
   test('closes an account', async () => {
-    const accountPage = await navigation.goToAccountPage('Roth IRA');
+    accountPage = await navigation.goToAccountPage('Roth IRA');
 
     await expect(accountPage.accountName).toHaveText('Roth IRA');
 
@@ -50,4 +51,50 @@ test.describe('Accounts', () => {
     await expect(accountPage.accountName).toHaveText('Closed: Roth IRA');
     await expect(page).toMatchThemeScreenshots();
   });
+
+  test.describe('Budgeted Accounts', () => {
+    // Reset filters
+    test.afterEach(async () => {
+      await accountPage.removeFilter(0);
+    });
+
+    test('creates a transfer from two existing transactions', async () => {
+      accountPage = await navigation.goToAccountPage('For budget');
+      await expect(accountPage.accountName).toHaveText('Budgeted Accounts');
+
+      await accountPage.filterByNote('Test Acc Transfer');
+
+      await accountPage.createSingleTransaction({
+        account: 'Ally Savings',
+        payee: '',
+        notes: 'Test Acc Transfer',
+        category: 'Food',
+        debit: '34.56',
+      });
+
+      await accountPage.createSingleTransaction({
+        account: 'HSBC',
+        payee: '',
+        notes: 'Test Acc Transfer',
+        category: 'Food',
+        credit: '34.56',
+      });
+
+      await accountPage.selectNthTransaction(0);
+      await accountPage.selectNthTransaction(1);
+      await accountPage.clickSelectAction('Make transfer');
+
+      let transaction = accountPage.getNthTransaction(0);
+      await expect(transaction.payee).toHaveText('Ally Savings');
+      await expect(transaction.category).toHaveText('Transfer');
+      await expect(transaction.credit).toHaveText('34.56');
+      await expect(transaction.account).toHaveText('HSBC');
+
+      transaction = accountPage.getNthTransaction(1);
+      await expect(transaction.payee).toHaveText('HSBC');
+      await expect(transaction.category).toHaveText('Transfer');
+      await expect(transaction.debit).toHaveText('34.56');
+      await expect(transaction.account).toHaveText('Ally Savings');
+    });
+  });
 });
diff --git a/packages/desktop-client/e2e/page-models/account-page.js b/packages/desktop-client/e2e/page-models/account-page.js
index cabd83f9535af1c77b258c2ad447209d093caefd..6edf858856038313f520340f0358fded24194775 100644
--- a/packages/desktop-client/e2e/page-models/account-page.js
+++ b/packages/desktop-client/e2e/page-models/account-page.js
@@ -25,6 +25,9 @@ export class AccountPage {
 
     this.filterButton = this.page.getByRole('button', { name: 'Filter' });
     this.filterSelectTooltip = this.page.getByTestId('filters-select-tooltip');
+
+    this.selectButton = this.page.getByTestId('transactions-select-button');
+    this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
   }
 
   /**
@@ -68,14 +71,21 @@ export class AccountPage {
     await this.cancelTransactionButton.click();
   }
 
+  async selectNthTransaction(index) {
+    const row = this.transactionTableRow.nth(index);
+    await row.getByTestId('select').click();
+  }
+
   /**
    * Retrieve the data for the nth-transaction.
    * 0-based index
    */
   getNthTransaction(index) {
     const row = this.transactionTableRow.nth(index);
+    const account = row.getByTestId('account');
 
     return {
+      ...(account ? { account } : {}),
       payee: row.getByTestId('payee'),
       notes: row.getByTestId('notes'),
       category: row.getByTestId('category'),
@@ -84,6 +94,11 @@ export class AccountPage {
     };
   }
 
+  async clickSelectAction(action) {
+    await this.selectButton.click();
+    await this.selectTooltip.getByRole('button', { name: action }).click();
+  }
+
   /**
    * Open the modal for closing the account.
    */
@@ -106,6 +121,15 @@ export class AccountPage {
     return new FilterTooltip(this.page.getByTestId('filters-menu-tooltip'));
   }
 
+  /**
+   * Filter to a specific note
+   */
+  async filterByNote(note) {
+    const filterTooltip = await this.filterBy('Note');
+    await this.page.keyboard.type(note);
+    await filterTooltip.applyButton.click();
+  }
+
   /**
    * Remove the nth filter
    */
@@ -117,6 +141,12 @@ export class AccountPage {
   }
 
   async _fillTransactionFields(transactionRow, transaction) {
+    if (transaction.account) {
+      await transactionRow.getByTestId('account').click();
+      await this.page.keyboard.type(transaction.account);
+      await this.page.keyboard.press('Tab');
+    }
+
     if (transaction.payee) {
       await transactionRow.getByTestId('payee').click();
       await this.page.keyboard.type(transaction.payee);
diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx
index 18a560f997984e3fa66a4e815938556cba700697..4db7abb7c276504b4960f69c00b6f03a7fa9b42e 100644
--- a/packages/desktop-client/src/components/accounts/Account.jsx
+++ b/packages/desktop-client/src/components/accounts/Account.jsx
@@ -5,6 +5,7 @@ import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom';
 import { debounce } from 'debounce';
 import { bindActionCreators } from 'redux';
 
+import { validForTransfer } from 'loot-core/client/transfer';
 import * as actions from 'loot-core/src/client/actions';
 import { useFilters } from 'loot-core/src/client/data-hooks/filters';
 import {
@@ -1059,6 +1060,52 @@ class AccountInternal extends PureComponent {
     this.props.pushModal('edit-rule', { rule });
   };
 
+  onSetTransfer = async ids => {
+    const onConfirmTransfer = async ids => {
+      this.setState({ workingHard: true });
+
+      const payees = await this.props.getPayees();
+      const { data: transactions } = await runQuery(
+        q('transactions')
+          .filter({ id: { $oneof: ids } })
+          .select('*'),
+      );
+      const [fromTrans, toTrans] = transactions;
+
+      if (transactions.length === 2 && validForTransfer(fromTrans, toTrans)) {
+        const fromPayee = payees.find(
+          p => p.transfer_acct === fromTrans.account,
+        );
+        const toPayee = payees.find(p => p.transfer_acct === toTrans.account);
+
+        const changes = {
+          updated: [
+            {
+              ...fromTrans,
+              payee: toPayee.id,
+              transfer_id: toTrans.id,
+            },
+            {
+              ...toTrans,
+              payee: fromPayee.id,
+              transfer_id: fromTrans.id,
+            },
+          ],
+        };
+
+        await send('transactions-batch-update', changes);
+      }
+
+      await this.refetchTransactions();
+    };
+
+    await this.checkForReconciledTransactions(
+      ids,
+      'batchEditWithReconciled',
+      onConfirmTransfer,
+    );
+  };
+
   onCondOpChange = (value, filters) => {
     this.setState({ conditionsOp: value });
     this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
@@ -1443,6 +1490,7 @@ class AccountInternal extends PureComponent {
                 onDeleteFilter={this.onDeleteFilter}
                 onApplyFilter={this.onApplyFilter}
                 onScheduleAction={this.onScheduleAction}
+                onSetTransfer={this.onSetTransfer}
               />
 
               <View style={{ flex: 1 }}>
diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx
index 789131335bb4ce7f419af0d386c3e7d1adce9b5a..eaade96bfb2b47eca427bad29cfff0c611f42a5d 100644
--- a/packages/desktop-client/src/components/accounts/Header.jsx
+++ b/packages/desktop-client/src/components/accounts/Header.jsx
@@ -79,6 +79,7 @@ export function AccountHeader({
   onCondOpChange,
   onDeleteFilter,
   onScheduleAction,
+  onSetTransfer,
 }) {
   const [menuOpen, setMenuOpen] = useState(false);
   const searchInput = useRef(null);
@@ -94,6 +95,9 @@ export function AccountHeader({
     canSync = !!accounts.find(account => !!account.account_id) && isUsingServer;
   }
 
+  // Only show the ability to make linked transfers on multi-account views.
+  const showMakeTransfer = !account;
+
   function onToggleSplits() {
     if (tableRef.current) {
       splitsExpanded.dispatch({
@@ -276,8 +280,10 @@ export function AccountHeader({
               onEdit={onBatchEdit}
               onUnlink={onBatchUnlink}
               onCreateRule={onCreateRule}
+              onSetTransfer={onSetTransfer}
               onScheduleAction={onScheduleAction}
               pushModal={pushModal}
+              showMakeTransfer={showMakeTransfer}
             />
           )}
           <Button
diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx
index e3a51e34355ff4a2b1b35b8a492fdab9a41bcb29..38cc9fc919fbb5207b7a4ec2c5c2657f11a9acd5 100644
--- a/packages/desktop-client/src/components/table.tsx
+++ b/packages/desktop-client/src/components/table.tsx
@@ -801,6 +801,7 @@ export function SelectedItemsButton({ name, keyHandlers, items, onSelect }) {
         type="bare"
         style={{ color: theme.pageTextPositive }}
         onClick={() => setMenuOpen(true)}
+        data-testid={name + '-select-button'}
       >
         <SvgExpandArrow
           width={8}
@@ -816,6 +817,7 @@ export function SelectedItemsButton({ name, keyHandlers, items, onSelect }) {
           width={200}
           style={{ padding: 0, backgroundColor: theme.menuBackground }}
           onClose={() => setMenuOpen(false)}
+          data-testid={name + '-select-tooltip'}
         >
           <Menu
             onMenuSelect={name => {
diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
index fa4cffa4b9a6dba476174a8dd02f67f2bd3cde8f..350470327556c9311b6a509a1604600770b29cf3 100644
--- a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
+++ b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
@@ -1,5 +1,7 @@
 import React, { useMemo } from 'react';
 
+import { validForTransfer } from 'loot-core/src/client/transfer';
+
 import { useSelectedItems } from '../../hooks/useSelected';
 import { Menu } from '../common/Menu';
 import { SelectedItemsButton } from '../table';
@@ -14,8 +16,10 @@ export function SelectedTransactionsButton({
   onEdit,
   onUnlink,
   onCreateRule,
+  onSetTransfer,
   onScheduleAction,
   pushModal,
+  showMakeTransfer,
 }) {
   const selectedItems = useSelectedItems();
 
@@ -43,6 +47,23 @@ export function SelectedTransactionsButton({
     );
   }, [types.preview, selectedItems, getTransaction]);
 
+  const canBeTransfer = useMemo(() => {
+    // only two selected
+    if (selectedItems.size !== 2) {
+      return false;
+    }
+    const transactions = [...selectedItems];
+    const fromTrans = getTransaction(transactions[0]);
+    const toTrans = getTransaction(transactions[1]);
+
+    // previously selected transactions aren't always present in current transaction list
+    if (!fromTrans || !toTrans) {
+      return false;
+    }
+
+    return validForTransfer(fromTrans, toTrans);
+  }, [selectedItems, getTransaction]);
+
   return (
     <SelectedItemsButton
       name="transactions"
@@ -91,6 +112,15 @@ export function SelectedTransactionsButton({
                       text: 'Create rule',
                     },
                   ]),
+              ...(showMakeTransfer
+                ? [
+                    {
+                      name: 'set-transfer',
+                      text: 'Make transfer',
+                      disabled: !canBeTransfer,
+                    },
+                  ]
+                : []),
               Menu.line,
               { type: Menu.label, name: 'Edit field' },
               { name: 'date', text: 'Date' },
@@ -145,6 +175,9 @@ export function SelectedTransactionsButton({
           case 'create-rule':
             onCreateRule([...selectedItems]);
             break;
+          case 'set-transfer':
+            onSetTransfer([...selectedItems]);
+            break;
           default:
             onEdit(name, [...selectedItems]);
         }
diff --git a/packages/loot-core/src/client/transfer.test.ts b/packages/loot-core/src/client/transfer.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38466059c68b69a245cfbe03b9dfccd60ce0cd15
--- /dev/null
+++ b/packages/loot-core/src/client/transfer.test.ts
@@ -0,0 +1,75 @@
+// @ts-strict-ignore
+import * as db from '../server/db';
+
+import * as transfer from './transfer';
+
+beforeEach(global.emptyDatabase());
+
+async function prepareDatabase() {
+  await db.insertAccount({ id: 'one', name: 'one' });
+  await db.insertAccount({ id: 'two', name: 'two' });
+  await db.insertPayee({ name: '', transfer_acct: 'one' });
+  await db.insertPayee({ name: '', transfer_acct: 'two' });
+}
+
+async function createTransaction(account: string, amount: number, extra = {}) {
+  const transaction = {
+    id: null,
+    account,
+    amount,
+    payee: await db.insertPayee({ name: 'Non-transfer ' + account }),
+    date: '2017-01-01',
+    ...extra,
+  };
+  transaction.id = await db.insertTransaction(transaction);
+  return await db.getTransaction(transaction.id);
+}
+
+describe('Transfer', () => {
+  test('Transfers are properly verified', async () => {
+    await prepareDatabase();
+
+    // happy path, two valid transactions
+    expect(
+      transfer.validForTransfer(
+        await createTransaction('one', 5),
+        await createTransaction('two', -5),
+      ),
+    ).toBeTruthy();
+
+    // amount not zeroed out
+    expect(
+      transfer.validForTransfer(
+        await createTransaction('one', 5),
+        await createTransaction('two', 5),
+      ),
+    ).toBeFalsy();
+
+    // amount not match
+    expect(
+      transfer.validForTransfer(
+        await createTransaction('one', 5),
+        await createTransaction('two', -6),
+      ),
+    ).toBeFalsy();
+
+    // accounts match
+    expect(
+      transfer.validForTransfer(
+        await createTransaction('one', 5),
+        await createTransaction('one', -5),
+      ),
+    ).toBeFalsy();
+
+    // one already a transfer
+    const existingTransfer = await createTransaction('one', 5);
+    expect(
+      transfer.validForTransfer(
+        await createTransaction('one', 5),
+        await createTransaction('two', -5, {
+          transfer_id: existingTransfer.id,
+        }),
+      ),
+    ).toBeFalsy();
+  });
+});
diff --git a/packages/loot-core/src/client/transfer.ts b/packages/loot-core/src/client/transfer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..21f58d5fde2a39bb04da00e7c8bebeebedde608b
--- /dev/null
+++ b/packages/loot-core/src/client/transfer.ts
@@ -0,0 +1,19 @@
+import type { TransactionEntity } from '../types/models';
+
+export function validForTransfer(
+  fromTransaction: TransactionEntity,
+  toTransaction: TransactionEntity,
+) {
+  if (
+    // no subtransactions
+    // not already a transfer
+    [fromTransaction, toTransaction].every(tran => {
+      return tran.transfer_id == null && tran.is_child === false;
+    }) &&
+    fromTransaction.account !== toTransaction.account && // belong to different accounts
+    fromTransaction.amount + toTransaction.amount === 0 // amount must zero each other out
+  ) {
+    return true;
+  }
+  return false;
+}
diff --git a/upcoming-release-notes/2398.md b/upcoming-release-notes/2398.md
new file mode 100644
index 0000000000000000000000000000000000000000..5fbebe1cf723bad823b07f48f9112cdba43dc3fe
--- /dev/null
+++ b/upcoming-release-notes/2398.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [twk3]
+---
+
+Add option to make a transfer from two selected transactions.