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 ? (