fix relinking a bank account (#8525)

* fix relinking an account

* new files
This commit is contained in:
youngcw
2026-07-20 23:13:35 +00:00
committed by GitHub
parent 5eab568ddd
commit 83da87bc6e
3 changed files with 169 additions and 42 deletions
@@ -0,0 +1,99 @@
import type {
AccountEntity,
SyncServerSimpleFinAccount,
} from '@actual-app/core/types/models';
import { describe, expect, it } from 'vitest';
import { computeInitialLinkState } from './SelectLinkedAccountsModal';
function makeLocalAccount(
overrides: Partial<AccountEntity> & { id: string },
): AccountEntity {
return {
name: overrides.id,
offbudget: 0,
closed: 0,
sort_order: 0,
last_reconciled: null,
tombstone: 0,
account_id: null,
bank: null,
bankName: null,
bankId: null,
mask: null,
official_name: null,
balance_current: null,
balance_available: null,
balance_limit: null,
account_sync_source: null,
last_sync: null,
bank_sync_status: null,
...overrides,
};
}
function makeExternalAccount(accountId: string): SyncServerSimpleFinAccount {
return { account_id: accountId, name: accountId, balance: 0 };
}
describe('computeInitialLinkState', () => {
it('preselects the upgrading account when there is exactly one unmatched external account', () => {
const localAccounts = [makeLocalAccount({ id: 'local-1' })];
const externalAccounts = [makeExternalAccount('ext-1')];
const { initiallyChosenAccounts } = computeInitialLinkState(
localAccounts,
externalAccounts,
'local-1',
);
expect(initiallyChosenAccounts['ext-1']).toBe('local-1');
});
it('does not preselect when there are multiple unmatched external accounts (regression for #8518)', () => {
const localAccounts = [makeLocalAccount({ id: 'local-1' })];
const externalAccounts = [
makeExternalAccount('ext-1'),
makeExternalAccount('ext-2'),
];
const { initiallyChosenAccounts } = computeInitialLinkState(
localAccounts,
externalAccounts,
'local-1',
);
expect(Object.values(initiallyChosenAccounts)).not.toContain('local-1');
});
it('does not preselect when upgradingAccountId is not set', () => {
const localAccounts = [makeLocalAccount({ id: 'local-1' })];
const externalAccounts = [makeExternalAccount('ext-1')];
const { initiallyChosenAccounts } = computeInitialLinkState(
localAccounts,
externalAccounts,
undefined,
);
expect(Object.values(initiallyChosenAccounts)).not.toContain('local-1');
});
it('leaves an already-linked upgrading account untouched', () => {
const localAccounts = [
makeLocalAccount({ id: 'local-1', account_id: 'ext-1' }),
];
const externalAccounts = [
makeExternalAccount('ext-1'),
makeExternalAccount('ext-2'),
];
const { initiallyChosenAccounts } = computeInitialLinkState(
localAccounts,
externalAccounts,
'local-1',
);
expect(initiallyChosenAccounts).toEqual({ 'ext-1': 'local-1' });
});
});
@@ -73,6 +73,57 @@ function isNewAccountOption(
);
}
/**
* Computes which local accounts should be pre-linked to which external
* accounts when the modal first opens. When `upgradingAccountId` is set and
* there's exactly one external account that doesn't already match a known
* local account, it's unambiguous which row it belongs to, so it's
* preselected. If there's more than one unmatched external account, guessing
* would risk claiming the wrong row (and hiding the account from every other
* row's picker), so it's left for the user to pick manually.
*/
export function computeInitialLinkState(
localAccounts: AccountEntity[],
externalAccounts: ExternalAccount[],
upgradingAccountId: string | undefined,
): {
initialDraftLinkAccounts: Map<string, 'linking' | 'unlinking'>;
initiallyChosenAccounts: Record<string, string>;
} {
const externalAccountIds = new Set(externalAccounts.map(a => a.account_id));
const initialDraftLinkAccounts = new Map<string, 'linking' | 'unlinking'>();
for (const acc of localAccounts) {
if (acc.account_id && externalAccountIds.has(acc.account_id)) {
initialDraftLinkAccounts.set(acc.account_id, 'linking');
}
}
const initiallyChosenAccounts: Record<string, string> = Object.fromEntries(
localAccounts
.filter(acc => acc.account_id)
.map(acc => [acc.account_id, acc.id]),
);
const unmatchedExternalAccounts = externalAccounts.filter(
account => initiallyChosenAccounts[account.account_id] == null,
);
if (
upgradingAccountId &&
unmatchedExternalAccounts.length === 1 &&
!Object.values(initiallyChosenAccounts).includes(upgradingAccountId)
) {
initiallyChosenAccounts[unmatchedExternalAccounts[0].account_id] =
upgradingAccountId;
initialDraftLinkAccounts.set(
unmatchedExternalAccounts[0].account_id,
'linking',
);
}
return { initialDraftLinkAccounts, initiallyChosenAccounts };
}
export type SelectLinkedAccountsModalProps =
| {
requisitionId: string;
@@ -161,48 +212,19 @@ export function SelectLinkedAccountsModal({
const dispatch = useDispatch();
const { data: allAccounts = [] } = useAccounts();
const localAccounts = allAccounts.filter(a => a.closed === 0);
const { initialDraftLinkAccounts, initiallyChosenAccounts } = useMemo(() => {
const externalAccountIds = new Set(
externalAccounts?.map(a => a.account_id) || [],
);
const initialDraftLinkAccounts = new Map<string, 'linking' | 'unlinking'>();
for (const acc of localAccounts) {
if (acc.account_id && externalAccountIds.has(acc.account_id)) {
initialDraftLinkAccounts.set(acc.account_id, 'linking');
}
}
const initiallyChosenAccounts = Object.fromEntries(
localAccounts
.filter(acc => acc.account_id)
.map(acc => [acc.account_id, acc.id]),
);
const preselectedExternalAccount =
propsWithSortedExternalAccounts.externalAccounts.find(
account => initiallyChosenAccounts[account.account_id] == null,
);
if (
upgradingAccountId &&
preselectedExternalAccount &&
!Object.values(initiallyChosenAccounts).includes(upgradingAccountId)
) {
initiallyChosenAccounts[preselectedExternalAccount.account_id] =
upgradingAccountId;
initialDraftLinkAccounts.set(
preselectedExternalAccount.account_id,
'linking',
);
}
return { initialDraftLinkAccounts, initiallyChosenAccounts };
}, [
localAccounts,
externalAccounts,
propsWithSortedExternalAccounts.externalAccounts,
upgradingAccountId,
]);
const { initialDraftLinkAccounts, initiallyChosenAccounts } = useMemo(
() =>
computeInitialLinkState(
localAccounts,
propsWithSortedExternalAccounts.externalAccounts,
upgradingAccountId,
),
[
localAccounts,
propsWithSortedExternalAccounts.externalAccounts,
upgradingAccountId,
],
);
const [draftLinkAccounts, setDraftLinkAccounts] = useState<
Map<string, 'linking' | 'unlinking'>
@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [youngcw]
---
Fix relinking a bank account that had been unlinked.