From 7340b48b706d8edf2aac79fffd17c2aa937e96c0 Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Thu, 20 Jul 2023 05:21:12 -0700
Subject: [PATCH] Include scheduled transactions for the month in Account's
 running balance (#1354)

---
 .../src/components/accounts/Account.js        | 386 ++++++++++--------
 .../transactions/TransactionList.js           |   2 +
 .../transactions/TransactionsTable.js         |  25 +-
 upcoming-release-notes/1354.md                |   6 +
 4 files changed, 236 insertions(+), 183 deletions(-)
 create mode 100644 upcoming-release-notes/1354.md

diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js
index 4b19fa501..a38f79ae4 100644
--- a/packages/desktop-client/src/components/accounts/Account.js
+++ b/packages/desktop-client/src/components/accounts/Account.js
@@ -74,10 +74,19 @@ function EmptyMessage({ onAdd }) {
   );
 }
 
-function AllTransactions({ account = {}, transactions, filtered, children }) {
+function AllTransactions({
+  account = {},
+  transactions,
+  balances,
+  showBalances,
+  filtered,
+  children,
+}) {
   const { id: accountId } = account;
   let scheduleData = useCachedSchedules();
 
+  transactions ??= [];
+
   let schedules = useMemo(
     () =>
       scheduleData
@@ -94,7 +103,7 @@ function AllTransactions({ account = {}, transactions, filtered, children }) {
 
   let prependTransactions = useMemo(() => {
     return schedules.map(schedule => ({
-      id: 'preview/' + schedule.id,
+      id: `preview/${schedule.id}`,
       payee: schedule._payee,
       account: schedule._account,
       amount: schedule._amount,
@@ -105,6 +114,36 @@ function AllTransactions({ account = {}, transactions, filtered, children }) {
     }));
   }, [schedules, accountId]);
 
+  let runningBalance = useMemo(() => {
+    if (!showBalances) {
+      return 0;
+    }
+
+    return balances && transactions?.length > 0
+      ? balances[transactions[0].id]?.balance ?? 0
+      : 0;
+  }, [showBalances, balances, transactions]);
+
+  let prependBalances = useMemo(() => {
+    if (!showBalances) {
+      return null;
+    }
+
+    // Reverse so we can calculate from earliest upcoming schedule.
+    let scheduledBalances = [...prependTransactions]
+      .reverse()
+      .map(scheduledTransaction => {
+        let amount =
+          (scheduledTransaction._inverse ? -1 : 1) *
+          scheduledTransaction.amount;
+        return {
+          balance: (runningBalance += amount),
+          id: scheduledTransaction.id,
+        };
+      });
+    return groupById(scheduledBalances);
+  }, [showBalances, prependTransactions, runningBalance]);
+
   let allTransactions = useMemo(() => {
     // Don't prepend scheduled transactions if we are filtering
     if (!filtered && prependTransactions.length > 0) {
@@ -113,10 +152,18 @@ function AllTransactions({ account = {}, transactions, filtered, children }) {
     return transactions;
   }, [filtered, prependTransactions, transactions]);
 
+  let allBalances = useMemo(() => {
+    // Don't prepend scheduled transactions if we are filtering
+    if (!filtered && prependBalances && balances) {
+      return { ...prependBalances, ...balances };
+    }
+    return balances;
+  }, [filtered, prependBalances, balances]);
+
   if (scheduleData == null) {
-    return children(null);
+    return children(transactions, balances);
   }
-  return children(allTransactions);
+  return children(allTransactions, allBalances);
 }
 
 function getField(field) {
@@ -152,7 +199,7 @@ class AccountInternal extends PureComponent {
       transactions: [],
       transactionsCount: 0,
       showBalances: props.showBalances,
-      balances: [],
+      balances: null,
       showCleared: props.showCleared,
       editingName: false,
       isAdding: false,
@@ -340,12 +387,11 @@ class AccountInternal extends PureComponent {
             transactionsFiltered: isFiltered,
             loading: false,
             workingHard: false,
+            balances: this.state.showBalances
+              ? await this.calculateBalances()
+              : null,
           },
           () => {
-            if (this.state.showBalances) {
-              this.calculateBalances();
-            }
-
             if (firstLoad) {
               this.table.current?.scrollToTop();
             }
@@ -372,7 +418,7 @@ class AccountInternal extends PureComponent {
           loading: true,
           search: '',
           showBalances: nextProps.showBalances,
-          balances: [],
+          balances: null,
           showCleared: nextProps.showCleared,
         },
         () => {
@@ -487,7 +533,7 @@ class AccountInternal extends PureComponent {
 
   async calculateBalances() {
     if (!this.canCalculateBalance()) {
-      return;
+      return null;
     }
 
     let { data } = await runQuery(
@@ -497,7 +543,7 @@ class AccountInternal extends PureComponent {
         .select([{ balance: { $sumOver: '$amount' } }]),
     );
 
-    this.setState({ balances: groupById(data) });
+    return groupById(data);
   }
 
   onAddTransaction = () => {
@@ -552,32 +598,36 @@ class AccountInternal extends PureComponent {
       case 'toggle-balance':
         if (this.state.showBalances) {
           this.props.savePrefs({ ['show-balances-' + accountId]: false });
-          this.setState({ showBalances: false, balances: [] });
+          this.setState({ showBalances: false, balances: null });
         } else {
-          this.setState({
-            transactions: [],
-            transactionCount: 0,
-            filters: [],
-            search: '',
-            sort: [],
-            showBalances: true,
-          });
-          this.fetchTransactions();
           this.props.savePrefs({ ['show-balances-' + accountId]: true });
-          this.calculateBalances();
+          this.setState(
+            {
+              transactions: [],
+              transactionCount: 0,
+              filters: [],
+              search: '',
+              sort: [],
+              showBalances: true,
+            },
+            () => {
+              this.fetchTransactions();
+            },
+          );
         }
         break;
       case 'remove-sorting': {
-        let filters = this.state.filters;
-        this.setState({ sort: [] });
-        if (filters.length > 0) {
-          this.applyFilters([...filters]);
-        } else {
-          this.fetchTransactions();
-        }
-        if (this.state.search !== '') {
-          this.onSearch(this.state.search);
-        }
+        this.setState({ sort: [] }, () => {
+          let filters = this.state.filters;
+          if (filters.length > 0) {
+            this.applyFilters([...filters]);
+          } else {
+            this.fetchTransactions();
+          }
+          if (this.state.search !== '') {
+            this.onSearch(this.state.search);
+          }
+        });
         break;
       }
       case 'toggle-cleared':
@@ -984,16 +1034,25 @@ class AccountInternal extends PureComponent {
       this.currentQuery = this.rootQuery.filter({
         [conditionsOpKey]: [...filters, ...customFilters],
       });
-      this.updateQuery(this.currentQuery, true);
-      this.setState({ filters: conditions });
+
+      this.setState({ filters: conditions }, () => {
+        this.updateQuery(this.currentQuery, true);
+      });
     } else {
-      this.setState({ transactions: [], transactionCount: 0 });
-      this.fetchTransactions();
-      this.setState({ filters: conditions });
-    }
+      this.setState(
+        {
+          transactions: [],
+          transactionCount: 0,
+          filters: conditions,
+        },
+        () => {
+          this.fetchTransactions();
 
-    if (this.state.sort.length !== 0) {
-      this.applySort();
+          if (this.state.sort.length !== 0) {
+            this.applySort();
+          }
+        },
+      );
     }
   };
 
@@ -1127,143 +1186,134 @@ class AccountInternal extends PureComponent {
       <AllTransactions
         account={account}
         transactions={transactions}
+        balances={balances}
+        showBalances={showBalances}
         filtered={transactionsFiltered}
       >
-        {allTransactions =>
-          allTransactions == null ? null : (
-            <SelectedProviderWithItems
-              name="transactions"
-              items={allTransactions}
-              fetchAllIds={this.fetchAllIds}
-              registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
-            >
-              <View style={[styles.page]}>
-                <AccountHeader
+        {(allTransactions, allBalances) => (
+          <SelectedProviderWithItems
+            name="transactions"
+            items={allTransactions}
+            fetchAllIds={this.fetchAllIds}
+            registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
+          >
+            <View style={[styles.page]}>
+              <AccountHeader
+                tableRef={this.table}
+                editingName={editingName}
+                isNameEditable={isNameEditable}
+                workingHard={workingHard}
+                account={account}
+                filterId={filterId}
+                filtersList={this.props.filtersList}
+                location={this.props.location}
+                accountName={accountName}
+                accountsSyncing={accountsSyncing}
+                accounts={accounts}
+                transactions={transactions}
+                showBalances={showBalances}
+                showExtraBalances={showExtraBalances}
+                showCleared={showCleared}
+                showEmptyMessage={showEmptyMessage}
+                balanceQuery={balanceQuery}
+                canCalculateBalance={this.canCalculateBalance}
+                reconcileAmount={reconcileAmount}
+                search={this.state.search}
+                filters={this.state.filters}
+                conditionsOp={this.state.conditionsOp}
+                savePrefs={this.props.savePrefs}
+                onSearch={this.onSearch}
+                onShowTransactions={this.onShowTransactions}
+                onMenuSelect={this.onMenuSelect}
+                onAddTransaction={this.onAddTransaction}
+                onToggleExtraBalances={this.onToggleExtraBalances}
+                onSaveName={this.onSaveName}
+                onExposeName={this.onExposeName}
+                onReconcile={this.onReconcile}
+                onDoneReconciling={this.onDoneReconciling}
+                onCreateReconciliationTransaction={
+                  this.onCreateReconciliationTransaction
+                }
+                onSync={this.onSync}
+                onImport={this.onImport}
+                onBatchDelete={this.onBatchDelete}
+                onBatchDuplicate={this.onBatchDuplicate}
+                onBatchEdit={this.onBatchEdit}
+                onBatchUnlink={this.onBatchUnlink}
+                onCreateRule={this.onCreateRule}
+                onUpdateFilter={this.onUpdateFilter}
+                onClearFilters={this.onClearFilters}
+                onReloadSavedFilter={this.onReloadSavedFilter}
+                onCondOpChange={this.onCondOpChange}
+                onDeleteFilter={this.onDeleteFilter}
+                onApplyFilter={this.onApplyFilter}
+                onScheduleAction={this.onScheduleAction}
+              />
+
+              <View style={{ flex: 1 }}>
+                <TransactionList
                   tableRef={this.table}
-                  editingName={editingName}
-                  isNameEditable={isNameEditable}
-                  workingHard={workingHard}
                   account={account}
-                  filterId={filterId}
-                  filtersList={this.props.filtersList}
-                  location={this.props.location}
-                  accountName={accountName}
-                  accountsSyncing={accountsSyncing}
-                  accounts={accounts}
                   transactions={transactions}
+                  allTransactions={allTransactions}
+                  animated={this.animated}
+                  loadMoreTransactions={() =>
+                    this.paged && this.paged.fetchNext()
+                  }
+                  accounts={accounts}
+                  category={category}
+                  categoryGroups={categoryGroups}
+                  payees={payees}
+                  balances={allBalances}
                   showBalances={showBalances}
-                  showExtraBalances={showExtraBalances}
                   showCleared={showCleared}
-                  showEmptyMessage={showEmptyMessage}
-                  balanceQuery={balanceQuery}
-                  canCalculateBalance={this.canCalculateBalance}
-                  isSorted={this.state.sort.length !== 0}
-                  reconcileAmount={reconcileAmount}
-                  search={this.state.search}
-                  filters={this.state.filters}
-                  conditionsOp={this.state.conditionsOp}
-                  savePrefs={this.props.savePrefs}
-                  onSearch={this.onSearch}
-                  onShowTransactions={this.onShowTransactions}
-                  onMenuSelect={this.onMenuSelect}
-                  onAddTransaction={this.onAddTransaction}
-                  onToggleExtraBalances={this.onToggleExtraBalances}
-                  onSaveName={this.onSaveName}
-                  onExposeName={this.onExposeName}
-                  onReconcile={this.onReconcile}
-                  onDoneReconciling={this.onDoneReconciling}
-                  onCreateReconciliationTransaction={
-                    this.onCreateReconciliationTransaction
+                  showAccount={
+                    !accountId ||
+                    accountId === 'offbudget' ||
+                    accountId === 'budgeted' ||
+                    accountId === 'uncategorized'
+                  }
+                  isAdding={this.state.isAdding}
+                  isNew={this.isNew}
+                  isMatched={this.isMatched}
+                  isFiltered={
+                    this.state.search !== '' || this.state.filters.length > 0
                   }
-                  onSync={this.onSync}
-                  onImport={this.onImport}
-                  onBatchDelete={this.onBatchDelete}
-                  onBatchDuplicate={this.onBatchDuplicate}
-                  onBatchEdit={this.onBatchEdit}
-                  onBatchUnlink={this.onBatchUnlink}
-                  onCreateRule={this.onCreateRule}
-                  onUpdateFilter={this.onUpdateFilter}
-                  onClearFilters={this.onClearFilters}
-                  onReloadSavedFilter={this.onReloadSavedFilter}
-                  onCondOpChange={this.onCondOpChange}
-                  onDeleteFilter={this.onDeleteFilter}
-                  onApplyFilter={this.onApplyFilter}
-                  onScheduleAction={this.onScheduleAction}
+                  dateFormat={dateFormat}
+                  hideFraction={hideFraction}
+                  addNotification={addNotification}
+                  renderEmpty={() =>
+                    showEmptyMessage ? (
+                      <EmptyMessage onAdd={() => replaceModal('add-account')} />
+                    ) : !loading ? (
+                      <View
+                        style={{
+                          marginTop: 20,
+                          textAlign: 'center',
+                          fontStyle: 'italic',
+                        }}
+                      >
+                        No transactions
+                      </View>
+                    ) : null
+                  }
+                  onChange={this.onTransactionsChange}
+                  onRefetch={this.refetchTransactions}
+                  onRefetchUpToRow={row =>
+                    this.paged.refetchUpToRow(row, {
+                      field: 'date',
+                      order: 'desc',
+                    })
+                  }
+                  onCloseAddTransaction={() =>
+                    this.setState({ isAdding: false })
+                  }
+                  onCreatePayee={this.onCreatePayee}
                 />
-
-                <View style={{ flex: 1 }}>
-                  <TransactionList
-                    tableRef={this.table}
-                    account={account}
-                    transactions={transactions}
-                    allTransactions={allTransactions}
-                    animated={this.animated}
-                    loadMoreTransactions={() =>
-                      this.paged && this.paged.fetchNext()
-                    }
-                    accounts={accounts}
-                    category={category}
-                    categoryGroups={categoryGroups}
-                    payees={payees}
-                    balances={
-                      showBalances && this.canCalculateBalance()
-                        ? balances
-                        : null
-                    }
-                    showCleared={showCleared}
-                    showAccount={
-                      !accountId ||
-                      accountId === 'offbudget' ||
-                      accountId === 'budgeted' ||
-                      accountId === 'uncategorized'
-                    }
-                    isAdding={this.state.isAdding}
-                    isNew={this.isNew}
-                    isMatched={this.isMatched}
-                    isFiltered={
-                      this.state.search !== '' || this.state.filters.length > 0
-                    }
-                    dateFormat={dateFormat}
-                    hideFraction={hideFraction}
-                    addNotification={addNotification}
-                    renderEmpty={() =>
-                      showEmptyMessage ? (
-                        <EmptyMessage
-                          onAdd={() => replaceModal('add-account')}
-                        />
-                      ) : !loading ? (
-                        <View
-                          style={{
-                            marginTop: 20,
-                            textAlign: 'center',
-                            fontStyle: 'italic',
-                          }}
-                        >
-                          No transactions
-                        </View>
-                      ) : null
-                    }
-                    onSort={this.onSort}
-                    sortField={this.state.sort.field}
-                    ascDesc={this.state.sort.ascDesc}
-                    onChange={this.onTransactionsChange}
-                    onRefetch={this.refetchTransactions}
-                    onRefetchUpToRow={row =>
-                      this.paged.refetchUpToRow(row, {
-                        field: 'date',
-                        order: 'desc',
-                      })
-                    }
-                    onCloseAddTransaction={() =>
-                      this.setState({ isAdding: false })
-                    }
-                    onCreatePayee={this.onCreatePayee}
-                  />
-                </View>
               </View>
-            </SelectedProviderWithItems>
-          )
-        }
+            </View>
+          </SelectedProviderWithItems>
+        )}
       </AllTransactions>
     );
   }
diff --git a/packages/desktop-client/src/components/transactions/TransactionList.js b/packages/desktop-client/src/components/transactions/TransactionList.js
index 2e9b20b49..96c4445b0 100644
--- a/packages/desktop-client/src/components/transactions/TransactionList.js
+++ b/packages/desktop-client/src/components/transactions/TransactionList.js
@@ -65,6 +65,7 @@ export default function TransactionList({
   categoryGroups,
   payees,
   balances,
+  showBalances,
   showCleared,
   showAccount,
   headerContent,
@@ -172,6 +173,7 @@ export default function TransactionList({
       accounts={accounts}
       categoryGroups={categoryGroups}
       payees={payees}
+      showBalances={showBalances}
       balances={balances}
       showCleared={showCleared}
       showAccount={showAccount}
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.js b/packages/desktop-client/src/components/transactions/TransactionsTable.js
index 6bd35ac6a..c2121ebfb 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.js
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.js
@@ -1223,11 +1223,7 @@ const Transaction = memo(function Transaction(props) {
       {showBalance && (
         <Cell
           name="balance"
-          value={
-            balance == null || isChild || isPreview
-              ? ''
-              : integerToCurrency(balance)
-          }
+          value={balance == null || isChild ? '' : integerToCurrency(balance)}
           valueStyle={{ color: balance < 0 ? colors.r4 : colors.g4 }}
           style={[styles.tnum, amountStyle]}
           width={88}
@@ -1486,6 +1482,7 @@ function TransactionTableInner({
       showCleared,
       showAccount,
       showCategory,
+      showBalances,
       balances,
       hideFraction,
       isNew,
@@ -1532,24 +1529,22 @@ function TransactionTableInner({
           transaction={trans}
           showAccount={showAccount}
           showCategory={showCategory}
-          showBalance={!!balances}
+          showBalance={showBalances}
           showCleared={showCleared}
           hovered={hovered}
           selected={selected}
           highlighted={false}
-          added={isNew && isNew(trans.id)}
-          expanded={isExpanded && isExpanded(trans.id)}
-          matched={isMatched && isMatched(trans.id)}
+          added={isNew?.(trans.id)}
+          expanded={isExpanded?.(trans.id)}
+          matched={isMatched?.(trans.id)}
           showZeroInDeposit={isChildDeposit}
-          balance={balances && balances[trans.id] && balances[trans.id].balance}
+          balance={balances?.[trans.id]?.balance}
           focusedField={editing && tableNavigator.focusedField}
           accounts={accounts}
           categoryGroups={categoryGroups}
           payees={payees}
           inheritedFields={
-            parent && parent.payee === trans.payee
-              ? new Set(['payee'])
-              : new Set()
+            parent?.payee === trans.payee ? new Set(['payee']) : new Set()
           }
           dateFormat={dateFormat}
           hideFraction={hideFraction}
@@ -1578,7 +1573,7 @@ function TransactionTableInner({
           hasSelected={props.selectedItems.size > 0}
           showAccount={props.showAccount}
           showCategory={props.showCategory}
-          showBalance={!!props.balances}
+          showBalance={props.showBalances}
           showCleared={props.showCleared}
           scrollWidth={scrollWidth}
           onSort={props.onSort}
@@ -1602,7 +1597,7 @@ function TransactionTableInner({
               payees={props.payees || []}
               showAccount={props.showAccount}
               showCategory={props.showCategory}
-              showBalance={!!props.balances}
+              showBalance={props.showBalances}
               showCleared={props.showCleared}
               dateFormat={dateFormat}
               hideFraction={props.hideFraction}
diff --git a/upcoming-release-notes/1354.md b/upcoming-release-notes/1354.md
new file mode 100644
index 000000000..555096a69
--- /dev/null
+++ b/upcoming-release-notes/1354.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [joel-jeremy]
+---
+
+Scheduled transactions for the month to show up in Account's running balance
-- 
GitLab