From f00672b5beb09020a1a048d557ae151e6ee27e83 Mon Sep 17 00:00:00 2001 From: mooons <10822203+mooons@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:09:09 -0700 Subject: [PATCH] [AI] Fix bank sync account relinking (#8365) --- .../modals/SelectLinkedAccountsModal.test.ts | 63 ++++++++++- .../modals/SelectLinkedAccountsModal.tsx | 107 +++++++++++++----- .../fix-bank-sync-relink-stale-accounts.md | 6 + 3 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 upcoming-release-notes/fix-bank-sync-relink-stale-accounts.md diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.test.ts b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.test.ts index d8b5fc3f4a..44578246bd 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.test.ts +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.test.ts @@ -4,7 +4,10 @@ import type { } from '@actual-app/core/types/models'; import { describe, expect, it } from 'vitest'; -import { computeInitialLinkState } from './SelectLinkedAccountsModal'; +import { + computeInitialLinkState, + getSelectableAccountOptions, +} from './SelectLinkedAccountsModal'; function makeLocalAccount( overrides: Partial & { id: string }, @@ -36,6 +39,16 @@ function makeExternalAccount(accountId: string): SyncServerSimpleFinAccount { return { account_id: accountId, name: accountId, balance: 0 }; } +const addOnBudgetAccountOption = { + id: 'new-on', + name: 'Create new account', +}; + +const addOffBudgetAccountOption = { + id: 'new-off', + name: 'Create new account (off budget)', +}; + describe('computeInitialLinkState', () => { it('preselects the upgrading account when there is exactly one unmatched external account', () => { const localAccounts = [makeLocalAccount({ id: 'local-1' })]; @@ -97,3 +110,51 @@ describe('computeInitialLinkState', () => { expect(initiallyChosenAccounts).toEqual({ 'ext-1': 'local-1' }); }); }); + +describe('getSelectableAccountOptions', () => { + it('allows relinking stale accounts from the same sync provider', () => { + const staleSimpleFinAccount = makeLocalAccount({ + id: 'actual-account-stale-simplefin', + name: 'Hilton Honors Aspire', + account_id: 'old-simplefin-id', + account_sync_source: 'simpleFin', + }); + const selectedVisibleSimpleFinAccount = makeLocalAccount({ + id: 'actual-account-visible-simplefin', + name: 'Business Gold Card', + account_id: 'visible-simplefin-id', + account_sync_source: 'simpleFin', + }); + const goCardlessAccount = makeLocalAccount({ + id: 'actual-account-gocardless', + name: 'Checking', + account_id: 'gocardless-id', + account_sync_source: 'goCardless', + }); + const manualAccount = makeLocalAccount({ + id: 'actual-account-manual', + name: 'Manual Card', + }); + + const options = getSelectableAccountOptions({ + localAccounts: [ + staleSimpleFinAccount, + selectedVisibleSimpleFinAccount, + goCardlessAccount, + manualAccount, + ], + selectedLocalAccountIds: new Set([selectedVisibleSimpleFinAccount.id]), + chosenAccount: undefined, + syncSource: 'simpleFin', + addOnBudgetAccountOption, + addOffBudgetAccountOption, + }); + + expect(options.map(option => option.name)).toEqual([ + 'Hilton Honors Aspire', + 'Manual Card', + 'Create new account', + 'Create new account (off budget)', + ]); + }); +}); diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx index 2183e9c1f9..ce17317d65 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx @@ -59,6 +59,43 @@ function useAddBudgetAccountOptions() { return { addOnBudgetAccountOption, addOffBudgetAccountOption }; } +type AddBudgetAccountOption = { + id: string; + name: string; +}; + +export function getSelectableAccountOptions({ + localAccounts, + selectedLocalAccountIds, + chosenAccount, + syncSource, + addOnBudgetAccountOption, + addOffBudgetAccountOption, +}: { + localAccounts: AccountEntity[]; + selectedLocalAccountIds: ReadonlySet; + chosenAccount: { id: string; name: string } | undefined; + syncSource: NonNullable; + addOnBudgetAccountOption: AddBudgetAccountOption; + addOffBudgetAccountOption: AddBudgetAccountOption; +}): AutocompleteItem[] { + const options: AutocompleteItem[] = localAccounts.filter(account => { + const isCurrentSelection = account.id === chosenAccount?.id; + + // Keep the current row's selection visible. Otherwise, offer only accounts + // that are unselected and either manual or linked to this provider. + return ( + isCurrentSelection || + (!selectedLocalAccountIds.has(account.id) && + (account.account_sync_source == null || + account.account_sync_source === syncSource)) + ); + }); + + options.push(addOnBudgetAccountOption, addOffBudgetAccountOption); + return options; +} + /** * Helper to determine if the chosen account option represents creating a new account. */ @@ -364,8 +401,20 @@ export function SelectLinkedAccountsModal({ dispatch(closeModal()); } - const unlinkedAccounts = localAccounts.filter( - account => !Object.values(chosenAccounts).includes(account.id), + // Only reserve accounts chosen by visible rows; stale provider links should + // stay selectable so users can relink them after reconnecting. + const currentExternalAccountIds = new Set( + propsWithSortedExternalAccounts.externalAccounts.map( + account => account.account_id, + ), + ); + + const selectedLocalAccountIds = new Set( + Object.entries(chosenAccounts) + .filter(([externalAccountId]) => + currentExternalAccountIds.has(externalAccountId), + ) + .map(([, localAccountId]) => localAccountId), ); function onSetLinkedAccount( @@ -504,7 +553,9 @@ export function SelectLinkedAccountsModal({ key={account.account_id} externalAccount={account} chosenAccount={getChosenAccount(account.account_id)} - unlinkedAccounts={unlinkedAccounts} + localAccounts={localAccounts} + selectedLocalAccountIds={selectedLocalAccountIds} + syncSource={syncSource} onSetLinkedAccount={onSetLinkedAccount} customStartingDate={getCustomStartingDate(account.account_id)} onSetCustomStartingDate={setCustomStartingDate} @@ -544,7 +595,9 @@ export function SelectLinkedAccountsModal({ key={item.id} externalAccount={item} chosenAccount={chosenAccount} - unlinkedAccounts={unlinkedAccounts} + localAccounts={localAccounts} + selectedLocalAccountIds={selectedLocalAccountIds} + syncSource={syncSource} onSetLinkedAccount={onSetLinkedAccount} customStartingDate={getCustomStartingDate( item.account_id, @@ -609,31 +662,15 @@ type StartingBalanceInfo = { type SharedAccountRowProps = { externalAccount: ExternalAccount; chosenAccount: { id: string; name: string } | undefined; - unlinkedAccounts: AccountEntity[]; + localAccounts: AccountEntity[]; + selectedLocalAccountIds: ReadonlySet; + syncSource: SelectLinkedAccountsModalProps['syncSource']; onSetLinkedAccount: ( externalAccount: ExternalAccount, localAccountId: string | null | undefined, ) => void; }; -function getAvailableAccountOptions( - unlinkedAccounts: AccountEntity[], - chosenAccount: { id: string; name: string } | undefined, - addOnBudgetAccountOption: { id: string; name: string }, - addOffBudgetAccountOption: { id: string; name: string }, -): AutocompleteItem[] { - const options: AutocompleteItem[] = [...unlinkedAccounts]; - if ( - chosenAccount && - chosenAccount.id !== addOnBudgetAccountOption.id && - chosenAccount.id !== addOffBudgetAccountOption.id - ) { - options.push(chosenAccount); - } - options.push(addOnBudgetAccountOption, addOffBudgetAccountOption); - return options; -} - type TableRowProps = SharedAccountRowProps & { customStartingDate: StartingBalanceInfo; onSetCustomStartingDate: ( @@ -677,7 +714,9 @@ function useStartingBalanceInfo(accountId: string | undefined) { function TableRow({ externalAccount, chosenAccount, - unlinkedAccounts, + localAccounts, + selectedLocalAccountIds, + syncSource, onSetLinkedAccount, customStartingDate, onSetCustomStartingDate, @@ -693,12 +732,14 @@ function TableRow({ showStartingOptions ? undefined : chosenAccount?.id, ); - const availableAccountOptions = getAvailableAccountOptions( - unlinkedAccounts, + const availableAccountOptions = getSelectableAccountOptions({ + localAccounts, + selectedLocalAccountIds, chosenAccount, + syncSource, addOnBudgetAccountOption, addOffBudgetAccountOption, - ); + }); return ( @@ -973,7 +1014,9 @@ type AccountCardProps = SharedAccountRowProps & { function AccountCard({ externalAccount, chosenAccount, - unlinkedAccounts, + localAccounts, + selectedLocalAccountIds, + syncSource, onSetLinkedAccount, customStartingDate, onSetCustomStartingDate, @@ -985,12 +1028,14 @@ function AccountCard({ const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const { t } = useTranslation(); - const availableAccountOptions = getAvailableAccountOptions( - unlinkedAccounts, + const availableAccountOptions = getSelectableAccountOptions({ + localAccounts, + selectedLocalAccountIds, chosenAccount, + syncSource, addOnBudgetAccountOption, addOffBudgetAccountOption, - ); + }); // Only show starting date options for new accounts being created const shouldShowStartingOptions = isNewAccountOption( diff --git a/upcoming-release-notes/fix-bank-sync-relink-stale-accounts.md b/upcoming-release-notes/fix-bank-sync-relink-stale-accounts.md new file mode 100644 index 0000000000..4dace38e74 --- /dev/null +++ b/upcoming-release-notes/fix-bank-sync-relink-stale-accounts.md @@ -0,0 +1,6 @@ +--- +category: Bugfixes +authors: [mooons] +--- + +Allow previously linked bank-sync accounts to be selected again when reconnecting the same provider.