From 1a262534573d214ea02e96fdac0848878efa313f Mon Sep 17 00:00:00 2001 From: Stephen Brown II Date: Fri, 6 Feb 2026 15:44:30 -0600 Subject: [PATCH] Update the Create Linked Account workflow to prompt for Starting Date and Balance (#6629) * feat: Add optional starting date and balance for bank sync accounts Adds the ability to specify a custom starting date and balance when linking new bank sync accounts in the Select Linked Accounts modal. Addresses: https://discord.com/channels/937901803608096828/1402270361625563186 Changes: - Frontend: Added inline date and amount input fields in the account linking table for new accounts - Redux: Extended link account actions to accept startingDate and startingBalance parameters - Backend: Updated account linking handlers to pass custom values to sync logic - Sync: Modified syncAccount and processBankSyncDownload to use custom starting date/balance for initial sync transactions Features: - Only displays starting options when creating new accounts (not upgrades) - AmountInput with smart sign detection based on account balance (negative for credit cards/loans) - Defaults to 90 days ago for date and 0 for balance - Mobile-responsive with separate AccountCard layout - Works across all sync providers: GoCardless, SimpleFIN, Pluggy.ai The custom starting balance is used directly for the starting balance transaction, and the custom starting date determines both the sync start date and the transaction date for the starting balance entry. * refactor: Extract shared types and components for starting balance inputs - Create CustomStartingSettings type to replace repeated inline type definitions - Extract StartingOptionsInput component to consolidate duplicate UI between mobile/desktop views - Create LinkAccountBasePayload type shared across GoCardless, SimpleFIN, and PluggyAI link functions - Apply same base type pattern to server-side link account handlers This simplifies the code introduced for custom starting date/balance when linking bank accounts. [autofix.ci] apply automated fixes * allow explicit zero values * refactor: add type guard for BankSyncError to remove oxlint-disable - Create isBankSyncError() type guard function with proper type narrowing - Remove oxlint-disable-next-line comment that suppressed the no-explicit-any rule - Add JSDoc comments for both isBankSyncError and handleSyncError functions - Remove redundant type assertion now that type guard narrows correctly * refactor: address code review nitpicks for SelectLinkedAccountsModal - Use locale-aware date formatting instead of toISOString() - Extract isNewAccountOption helper to reduce duplication - Align AccountCardProps type definition pattern with TableRowProps * Add placeholder date/balance for already linked accounts * [autofix.ci] apply automated fixes * Use StartingBalanceInfo only, and add mobile view --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../src/accounts/accountsSlice.ts | 51 ++- .../modals/SelectLinkedAccountsModal.tsx | 403 ++++++++++++++++-- packages/loot-core/src/server/accounts/app.ts | 61 ++- .../loot-core/src/server/accounts/sync.ts | 40 +- upcoming-release-notes/6629.md | 6 + 5 files changed, 497 insertions(+), 64 deletions(-) create mode 100644 upcoming-release-notes/6629.md diff --git a/packages/desktop-client/src/accounts/accountsSlice.ts b/packages/desktop-client/src/accounts/accountsSlice.ts index d58feb9207..6620881cfd 100644 --- a/packages/desktop-client/src/accounts/accountsSlice.ts +++ b/packages/desktop-client/src/accounts/accountsSlice.ts @@ -245,17 +245,30 @@ export const unlinkAccount = createAppAsyncThunk( }, ); -type LinkAccountPayload = { +// Shared base type for link account payloads +type LinkAccountBasePayload = { + upgradingId?: AccountEntity['id']; + offBudget?: boolean; + startingDate?: string; + startingBalance?: number; +}; + +type LinkAccountPayload = LinkAccountBasePayload & { requisitionId: string; account: SyncServerGoCardlessAccount; - upgradingId?: AccountEntity['id'] | undefined; - offBudget?: boolean | undefined; }; export const linkAccount = createAppAsyncThunk( `${sliceName}/linkAccount`, async ( - { requisitionId, account, upgradingId, offBudget }: LinkAccountPayload, + { + requisitionId, + account, + upgradingId, + offBudget, + startingDate, + startingBalance, + }: LinkAccountPayload, { dispatch }, ) => { await send('gocardless-accounts-link', { @@ -263,50 +276,64 @@ export const linkAccount = createAppAsyncThunk( account, upgradingId, offBudget, + startingDate, + startingBalance, }); dispatch(markPayeesDirty()); dispatch(markAccountsDirty()); }, ); -type LinkAccountSimpleFinPayload = { +type LinkAccountSimpleFinPayload = LinkAccountBasePayload & { externalAccount: SyncServerSimpleFinAccount; - upgradingId?: AccountEntity['id'] | undefined; - offBudget?: boolean | undefined; }; export const linkAccountSimpleFin = createAppAsyncThunk( `${sliceName}/linkAccountSimpleFin`, async ( - { externalAccount, upgradingId, offBudget }: LinkAccountSimpleFinPayload, + { + externalAccount, + upgradingId, + offBudget, + startingDate, + startingBalance, + }: LinkAccountSimpleFinPayload, { dispatch }, ) => { await send('simplefin-accounts-link', { externalAccount, upgradingId, offBudget, + startingDate, + startingBalance, }); dispatch(markPayeesDirty()); dispatch(markAccountsDirty()); }, ); -type LinkAccountPluggyAiPayload = { +type LinkAccountPluggyAiPayload = LinkAccountBasePayload & { externalAccount: SyncServerPluggyAiAccount; - upgradingId?: AccountEntity['id']; - offBudget?: boolean; }; export const linkAccountPluggyAi = createAppAsyncThunk( `${sliceName}/linkAccountPluggyAi`, async ( - { externalAccount, upgradingId, offBudget }: LinkAccountPluggyAiPayload, + { + externalAccount, + upgradingId, + offBudget, + startingDate, + startingBalance, + }: LinkAccountPluggyAiPayload, { dispatch }, ) => { await send('pluggyai-accounts-link', { externalAccount, upgradingId, offBudget, + startingDate, + startingBalance, }); dispatch(markPayeesDirty()); dispatch(markAccountsDirty()); diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx index 91e7aecedb..1e0f88bad2 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx @@ -1,15 +1,18 @@ -import React, { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Button } from '@actual-app/components/button'; import { useResponsive } from '@actual-app/components/hooks/useResponsive'; +import { Input } from '@actual-app/components/input'; import { SpaceBetween } from '@actual-app/components/space-between'; import { styles } from '@actual-app/components/styles'; import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { Tooltip } from '@actual-app/components/tooltip'; import { View } from '@actual-app/components/view'; +import { format as formatDate, parseISO } from 'date-fns'; +import { currentDay, subDays } from 'loot-core/shared/months'; import { type AccountEntity, type SyncServerGoCardlessAccount, @@ -41,9 +44,13 @@ import { Table, TableHeader, } from '@desktop-client/components/table'; +import { AmountInput } from '@desktop-client/components/util/AmountInput'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; import { useFormat } from '@desktop-client/hooks/useFormat'; import { closeModal } from '@desktop-client/modals/modalsSlice'; +import { transactions } from '@desktop-client/queries'; +import { liveQuery } from '@desktop-client/queries/liveQuery'; import { useDispatch } from '@desktop-client/redux'; function useAddBudgetAccountOptions() { @@ -61,6 +68,20 @@ function useAddBudgetAccountOptions() { return { addOnBudgetAccountOption, addOffBudgetAccountOption }; } +/** + * Helper to determine if the chosen account option represents creating a new account. + */ +function isNewAccountOption( + chosenAccountId: string | undefined, + addOnBudgetOptionId: string, + addOffBudgetOptionId: string, +): boolean { + return ( + chosenAccountId === addOnBudgetOptionId || + chosenAccountId === addOffBudgetOptionId + ); +} + export type SelectLinkedAccountsModalProps = | { requisitionId: string; @@ -129,6 +150,9 @@ export function SelectLinkedAccountsModal({ ); }, ); + const [customStartingDates, setCustomStartingDates] = useState< + Record + >({}); const { addOnBudgetAccountOption, addOffBudgetAccountOption } = useAddBudgetAccountOptions(); @@ -158,6 +182,14 @@ export function SelectLinkedAccountsModal({ } // Finally link the matched account + const customSettings = customStartingDates[chosenExternalAccountId]; + const startingDate = + customSettings?.date && customSettings.date.trim() !== '' + ? customSettings.date + : undefined; + const startingBalance = + customSettings?.amount != null ? customSettings.amount : undefined; + if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') { dispatch( linkAccountSimpleFin({ @@ -171,6 +203,8 @@ export function SelectLinkedAccountsModal({ ? chosenLocalAccountId : undefined, offBudget, + startingDate, + startingBalance, }), ); } else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') { @@ -186,6 +220,8 @@ export function SelectLinkedAccountsModal({ ? chosenLocalAccountId : undefined, offBudget, + startingDate, + startingBalance, }), ); } else { @@ -202,6 +238,8 @@ export function SelectLinkedAccountsModal({ ? chosenLocalAccountId : undefined, offBudget, + startingDate, + startingBalance, }), ); } @@ -255,6 +293,33 @@ export function SelectLinkedAccountsModal({ return localAccounts.find(acc => acc.id === chosenId); }; + // Memoize default starting settings to avoid repeated calculations + const defaultStartingSettings = useMemo( + () => ({ + date: subDays(currentDay(), 90), + amount: 0, + }), + [], + ); + + const getCustomStartingDate = (accountId: string) => { + if (customStartingDates[accountId]) { + return customStartingDates[accountId]; + } + // Default to 90 days ago (matches server default) + return defaultStartingSettings; + }; + + const setCustomStartingDate = ( + accountId: string, + settings: StartingBalanceInfo, + ) => { + setCustomStartingDates(prev => ({ + ...prev, + [accountId]: settings, + })); + }; + const label = useMemo(() => { const s = new Set(draftLinkAccounts.values()); if (s.has('linking') && s.has('unlinking')) { @@ -325,6 +390,8 @@ export function SelectLinkedAccountsModal({ chosenAccount={getChosenAccount(account.account_id)} unlinkedAccounts={unlinkedAccounts} onSetLinkedAccount={onSetLinkedAccount} + customStartingDate={getCustomStartingDate(account.account_id)} + onSetCustomStartingDate={setCustomStartingDate} /> ))} @@ -333,35 +400,44 @@ export function SelectLinkedAccountsModal({ style={{ ...styles.tableContainer, height: 300, flex: 'unset' }} > - - - + + + - + + + - + items={propsWithSortedExternalAccounts.externalAccounts.map( - account => ({ - ...account, - id: account.account_id, - }), + acc => ({ ...acc, id: acc.account_id }), )} style={{ backgroundColor: theme.tableHeaderBackground }} - renderItem={({ item }) => ( - + renderItem={({ item }) => { + const chosenAccount = getChosenAccount(item.account_id); + // Only show starting options for new accounts being created + const shouldShowStartingOptions = isNewAccountOption( + chosenAccount?.id, + addOnBudgetAccountOption.id, + addOffBudgetAccountOption.id, + ); + + return ( - - )} + ); + }} /> )} @@ -407,6 +483,11 @@ type ExternalAccount = | SyncServerSimpleFinAccount | SyncServerPluggyAiAccount; +type StartingBalanceInfo = { + date: string; + amount: number; +}; + type SharedAccountRowProps = { externalAccount: ExternalAccount; chosenAccount: { id: string; name: string } | undefined; @@ -435,19 +516,64 @@ function getAvailableAccountOptions( return options; } -type TableRowProps = SharedAccountRowProps; +type TableRowProps = SharedAccountRowProps & { + customStartingDate: StartingBalanceInfo; + onSetCustomStartingDate: ( + accountId: string, + settings: StartingBalanceInfo, + ) => void; + showStartingOptions: boolean; +}; + +function useStartingBalanceInfo(accountId: string | undefined) { + const [info, setInfo] = useState(null); + + useEffect(() => { + if (!accountId) { + setInfo(null); + return; + } + + const query = transactions(accountId) + .filter({ starting_balance_flag: true }) + .select(['date', 'amount']) + .limit(1); + + const live = liveQuery(query, { + onData: data => { + setInfo(data?.[0] ?? null); + }, + onError: () => { + setInfo(null); + }, + }); + + return () => { + live?.unsubscribe(); + }; + }, [accountId]); + + return info; +} function TableRow({ externalAccount, chosenAccount, unlinkedAccounts, onSetLinkedAccount, + customStartingDate, + onSetCustomStartingDate, + showStartingOptions, }: TableRowProps) { const [focusedField, setFocusedField] = useState(null); const { addOnBudgetAccountOption, addOffBudgetAccountOption } = useAddBudgetAccountOptions(); const format = useFormat(); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const { t } = useTranslation(); + const startingBalanceInfo = useStartingBalanceInfo( + showStartingOptions ? undefined : chosenAccount?.id, + ); const availableAccountOptions = getAvailableAccountOptions( unlinkedAccounts, @@ -458,7 +584,8 @@ function TableRow({ return ( - + {/* Institution to Sync */} + - + {/* Bank Account To Sync */} + - + {/* Balance */} + {externalAccount.balance != null ? ( @@ -495,6 +624,7 @@ function TableRow({ )} + {/* Account in Actual */} + {showStartingOptions ? ( + + ) : ( + <> + {/* Starting Date */} + + {startingBalanceInfo ? ( + + {formatDate(parseISO(startingBalanceInfo.date), dateFormat)} + + ) : null} + + {/* Starting Balance */} + + {startingBalanceInfo ? ( + + + {format(startingBalanceInfo.amount, 'financial')} + + + ) : null} + + + )} + {/* Actions */} {chosenAccount ? (