From f1caf21deb9488ede8741ce3ed06a2e584309fee Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Sun, 7 Jul 2024 09:29:27 -0700
Subject: [PATCH] Assign schedule to both transactions if schedule is a
 transfer (#2990)

* Assign schedule to both transactions if schedule is a transfer

* Release notes

* Migration for old scheduled transfer transactions
---
 .../src/components/accounts/Account.jsx       | 29 +++--------------
 .../mobile/accounts/AccountTransactions.jsx   | 18 ++++-------
 .../transactions/TransactionsTable.jsx        | 10 +++---
 .../1720310586000_link_transfer_schedules.sql | 19 +++++++++++
 .../src/client/data-hooks/schedules.tsx       | 32 ++++++++++++++++++-
 .../loot-core/src/server/accounts/transfer.ts |  2 ++
 upcoming-release-notes/2990.md                |  6 ++++
 7 files changed, 74 insertions(+), 42 deletions(-)
 create mode 100644 packages/loot-core/migrations/1720310586000_link_transfer_schedules.sql
 create mode 100644 upcoming-release-notes/2990.md

diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx
index ef660cbef..3f62365ad 100644
--- a/packages/desktop-client/src/components/accounts/Account.jsx
+++ b/packages/desktop-client/src/components/accounts/Account.jsx
@@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid';
 
 import { validForTransfer } from 'loot-core/client/transfer';
 import { useFilters } from 'loot-core/src/client/data-hooks/filters';
-import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules';
+import {
+  SchedulesProvider,
+  useDefaultSchedulesQueryTransform,
+} from 'loot-core/src/client/data-hooks/schedules';
 import * as queries from 'loot-core/src/client/queries';
 import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
 import { send, listen } from 'loot-core/src/platform/client/fetch';
@@ -1837,29 +1840,7 @@ export function Account() {
   const savedFiters = useFilters();
   const actionCreators = useActions();
 
-  const transform = useMemo(() => {
-    const filterByAccount = queries.getAccountFilter(params.id, '_account');
-    const filterByPayee = queries.getAccountFilter(
-      params.id,
-      '_payee.transfer_acct',
-    );
-
-    return q => {
-      q = q.filter({
-        $and: [{ '_account.closed': false }],
-      });
-      if (params.id) {
-        if (params.id === 'uncategorized') {
-          q = q.filter({ next_date: null });
-        } else {
-          q = q.filter({
-            $or: [filterByAccount, filterByPayee],
-          });
-        }
-      }
-      return q.orderBy({ next_date: 'desc' });
-    };
-  }, [params.id]);
+  const transform = useDefaultSchedulesQueryTransform(params.id);
 
   return (
     <SchedulesProvider transform={transform}>
diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx
index cf01dba15..4711d0523 100644
--- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx
+++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx
@@ -7,7 +7,6 @@ import React, {
 } from 'react';
 import { useDispatch } from 'react-redux';
 
-import memoizeOne from 'memoize-one';
 import { useDebounceCallback } from 'usehooks-ts';
 
 import {
@@ -20,7 +19,10 @@ import {
   syncAndDownload,
   updateAccount,
 } from 'loot-core/client/actions';
-import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules';
+import {
+  SchedulesProvider,
+  useDefaultSchedulesQueryTransform,
+} from 'loot-core/client/data-hooks/schedules';
 import * as queries from 'loot-core/client/queries';
 import { pagedQuery } from 'loot-core/client/query-helpers';
 import { listen, send } from 'loot-core/platform/client/fetch';
@@ -39,6 +41,7 @@ import { AddTransactionButton } from '../transactions/AddTransactionButton';
 import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances';
 
 export function AccountTransactions({ account, pending, failed }) {
+  const schedulesTransform = useDefaultSchedulesQueryTransform(account.id);
   return (
     <Page
       header={
@@ -52,7 +55,7 @@ export function AccountTransactions({ account, pending, failed }) {
       }
       padding={0}
     >
-      <SchedulesProvider transform={getSchedulesTransform(account.id)}>
+      <SchedulesProvider transform={schedulesTransform}>
         <TransactionListWithPreviews account={account} />
       </SchedulesProvider>
     </Page>
@@ -132,15 +135,6 @@ function AccountName({ account, pending, failed }) {
   );
 }
 
-const getSchedulesTransform = memoizeOne(id => {
-  const filter = queries.getAccountFilter(id, '_account');
-
-  return q => {
-    q = q.filter({ $and: [filter, { '_account.closed': false }] });
-    return q.orderBy({ next_date: 'desc' });
-  };
-});
-
 function TransactionListWithPreviews({ account }) {
   const [currentQuery, setCurrentQuery] = useState();
   const [isSearching, setIsSearching] = useState(false);
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index d3225f371..f0880f907 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -604,17 +604,17 @@ function PayeeIcons({
   transferAccount,
   onNavigateToTransferAccount,
   onNavigateToSchedule,
-  children,
 }) {
   const scheduleId = transaction.schedule;
   const scheduleData = useCachedSchedules();
-  const schedule = scheduleData
-    ? scheduleData.schedules.find(s => s.id === scheduleId)
-    : null;
+  const schedule =
+    scheduleId && scheduleData
+      ? scheduleData.schedules.find(s => s.id === scheduleId)
+      : null;
 
   if (schedule == null && transferAccount == null) {
     // Neither a valid scheduled transaction nor a transfer.
-    return children;
+    return null;
   }
 
   const buttonStyle = {
diff --git a/packages/loot-core/migrations/1720310586000_link_transfer_schedules.sql b/packages/loot-core/migrations/1720310586000_link_transfer_schedules.sql
new file mode 100644
index 000000000..f1f6e11d4
--- /dev/null
+++ b/packages/loot-core/migrations/1720310586000_link_transfer_schedules.sql
@@ -0,0 +1,19 @@
+BEGIN TRANSACTION;
+
+UPDATE transactions AS t1
+SET schedule = (
+    SELECT t2.schedule FROM transactions AS t2
+    WHERE t2.id = t1.transferred_id
+      AND t2.schedule IS NOT NULL
+    LIMIT 1
+)
+WHERE t1.schedule IS NULL
+AND t1.transferred_id IS NOT NULL
+AND EXISTS (
+  SELECT 1 FROM transactions AS t2
+  WHERE t2.id = t1.transferred_id
+    AND t2.schedule IS NOT NULL
+  LIMIT 1
+);
+
+COMMIT;
diff --git a/packages/loot-core/src/client/data-hooks/schedules.tsx b/packages/loot-core/src/client/data-hooks/schedules.tsx
index 2f6117dea..5c18d8779 100644
--- a/packages/loot-core/src/client/data-hooks/schedules.tsx
+++ b/packages/loot-core/src/client/data-hooks/schedules.tsx
@@ -1,9 +1,16 @@
 // @ts-strict-ignore
-import React, { createContext, useEffect, useState, useContext } from 'react';
+import React, {
+  createContext,
+  useEffect,
+  useState,
+  useContext,
+  useMemo,
+} from 'react';
 
 import { q, type Query } from '../../shared/query';
 import { getStatus, getHasTransactionsQuery } from '../../shared/schedules';
 import { type ScheduleEntity } from '../../types/models';
+import { getAccountFilter } from '../queries';
 import { liveQuery } from '../query-helpers';
 
 export type ScheduleStatusType = ReturnType<typeof getStatus>;
@@ -84,3 +91,26 @@ export function SchedulesProvider({ transform, children }) {
 export function useCachedSchedules() {
   return useContext(SchedulesContext);
 }
+
+export function useDefaultSchedulesQueryTransform(accountId) {
+  return useMemo(() => {
+    const filterByAccount = getAccountFilter(accountId, '_account');
+    const filterByPayee = getAccountFilter(accountId, '_payee.transfer_acct');
+
+    return (q: Query) => {
+      q = q.filter({
+        $and: [{ '_account.closed': false }],
+      });
+      if (accountId) {
+        if (accountId === 'uncategorized') {
+          q = q.filter({ next_date: null });
+        } else {
+          q = q.filter({
+            $or: [filterByAccount, filterByPayee],
+          });
+        }
+      }
+      return q.orderBy({ next_date: 'desc' });
+    };
+  }, [accountId]);
+}
diff --git a/packages/loot-core/src/server/accounts/transfer.ts b/packages/loot-core/src/server/accounts/transfer.ts
index f3c41e434..04545bf78 100644
--- a/packages/loot-core/src/server/accounts/transfer.ts
+++ b/packages/loot-core/src/server/accounts/transfer.ts
@@ -80,6 +80,7 @@ export async function addTransfer(transaction, transferredAccount) {
     date: transaction.date,
     transfer_id: transaction.id,
     notes: transaction.notes || null,
+    schedule: transaction.schedule,
     cleared: false,
   });
 
@@ -130,6 +131,7 @@ export async function updateTransfer(transaction, transferredAccount) {
     date: transaction.date,
     notes: transaction.notes,
     amount: -transaction.amount,
+    schedule: transaction.schedule,
   });
 
   const categoryCleared = await clearCategory(transaction, transferredAccount);
diff --git a/upcoming-release-notes/2990.md b/upcoming-release-notes/2990.md
new file mode 100644
index 000000000..17da16756
--- /dev/null
+++ b/upcoming-release-notes/2990.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [joel-jeremy]
+---
+
+Assign schedule to both transactions if schedule is a transfer
-- 
GitLab