mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-25 05:54:03 -05:00
[AI] Fix bank sync account relinking (#8365)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user