[AI] Fix bank sync account relinking (#8365)

This commit is contained in:
mooons
2026-07-24 11:09:09 +00:00
committed by GitHub
parent 6019bf5ac6
commit f00672b5be
3 changed files with 144 additions and 32 deletions
@@ -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<AccountEntity> & { 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)',
]);
});
});
@@ -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<string>;
chosenAccount: { id: string; name: string } | undefined;
syncSource: NonNullable<AccountEntity['account_sync_source']>;
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<string>;
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 (
<Row style={{ backgroundColor: theme.tableBackground }}>
@@ -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(
@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [mooons]
---
Allow previously linked bank-sync accounts to be selected again when reconnecting the same provider.