Compare commits

...

5 Commits

Author SHA1 Message Date
Cursor Agent
31967e36a4 [AI] Resolve bank sync PR merge conflicts
Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 03:09:42 +00:00
github-actions[bot]
c2150e5888 Update VRT screenshots
Auto-generated by VRT workflow

PR: #7449
2026-04-10 02:04:26 +00:00
lelemm
6b0242fa49 code review 2026-04-09 21:07:23 +00:00
lelemm
d8eba18a72 Merge branch 'master' into feat/banksync-page 2026-04-09 20:47:43 +00:00
lelemm
c91eea5439 Bank sync refactor extracted from plugins 2026-04-09 19:41:10 +00:00
15 changed files with 1024 additions and 690 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -34,6 +34,9 @@
"#polyfills": "./src/polyfills.ts",
"#components/forms": "./src/components/forms/index.tsx",
"#components/banksync": "./src/components/banksync/index.tsx",
"#components/banksync/bankSyncUtils": "./src/components/banksync/bankSyncUtils.ts",
"#components/banksync/BuiltInProviders": "./src/components/banksync/BuiltInProviders.tsx",
"#components/banksync/useBuiltInBankSyncProviders": "./src/components/banksync/useBuiltInBankSyncProviders.ts",
"#components/banksync/useBankSyncAccountSettings": "./src/components/banksync/useBankSyncAccountSettings.ts",
"#components/budget": "./src/components/budget/index.tsx",
"#components/budget/goals/actions": "./src/components/budget/goals/actions.ts",

View File

@@ -0,0 +1,213 @@
import { Dialog, DialogTrigger } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button, ButtonWithLoading } from '@actual-app/components/button';
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
import { Menu } from '@actual-app/components/menu';
import { Paragraph } from '@actual-app/components/paragraph';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { Warning } from '#components/alerts';
import { Link } from '#components/common/Link';
import type { BuiltInBankSyncProviderState } from './useBuiltInBankSyncProviders';
type BuiltInProvidersProps = {
providers: BuiltInBankSyncProviderState[];
syncServerStatus: 'offline' | 'no-server' | 'online';
showPermissionWarning: boolean;
providersNeedingConfiguration: BuiltInBankSyncProviderState[];
};
export function BuiltInProviders({
providers,
syncServerStatus,
showPermissionWarning,
providersNeedingConfiguration,
}: BuiltInProvidersProps) {
const { t } = useTranslation();
return (
<View style={{ gap: 12 }}>
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 20, fontWeight: 600 }}>
<Trans>Providers</Trans>
</Text>
<Paragraph style={{ fontSize: 15, color: theme.pageTextSubdued }}>
<Trans>
Set up a bank sync provider, then link new accounts or connect an
existing Actual account.
</Trans>
</Paragraph>
</View>
{syncServerStatus !== 'online' ? (
<View
style={{
border: `1px solid ${theme.tableBorder}`,
borderRadius: 8,
padding: 16,
backgroundColor: theme.tableBackground,
}}
>
<Button isDisabled style={{ padding: '10px 0', fontSize: 15 }}>
<Trans>Set up bank sync</Trans>
</Button>
<Paragraph style={{ fontSize: 15, marginTop: 10 }}>
<Trans>
Connect to an Actual server to set up{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync"
linkColor="muted"
>
automatic syncing
</Link>
.
</Trans>
</Paragraph>
</View>
) : (
<View
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: 12,
}}
>
{providers.map(provider => (
<View
key={provider.id}
data-testid={`bank-sync-provider-${provider.id}`}
style={{
border: `1px solid ${theme.tableBorder}`,
borderRadius: 8,
padding: 16,
backgroundColor: theme.tableBackground,
gap: 16,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
}}
>
<View
style={{
gap: 6,
flex: 1,
}}
>
<Text style={{ fontSize: 17, fontWeight: 600 }}>
{provider.displayName}
</Text>
<Text
style={{
color: provider.isConfigured
? theme.noticeTextDark
: theme.pageTextSubdued,
fontSize: 13,
fontWeight: 500,
}}
>
{provider.isConfigured ? (
<Trans>Configured</Trans>
) : (
<Trans>Not configured</Trans>
)}
</Text>
</View>
{provider.isConfigured && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('{{provider}} menu', {
provider: provider.displayName,
})}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
void provider.onReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset {{provider}} credentials', {
provider: provider.displayName,
}),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<Button
variant="bare"
isDisabled={!provider.canConfigure}
onPress={() => provider.onConfigure()}
>
{provider.isConfigured ? (
<Trans>Edit setup</Trans>
) : (
<Trans>Set up</Trans>
)}
</Button>
<ButtonWithLoading
variant="primary"
isDisabled={!provider.isConfigured}
isLoading={provider.isLoading}
onPress={() => provider.onLink()}
>
<Trans>Link bank account</Trans>
</ButtonWithLoading>
</View>
</View>
))}
</View>
)}
{showPermissionWarning && (
<Warning>
<Trans>
You don&apos;t have the required permissions to configure bank sync
providers. Please contact an Admin to configure
</Trans>{' '}
{providersNeedingConfiguration
.map(provider => provider.displayName)
.join(' or ')}
.
</Warning>
)}
</View>
);
}

View File

@@ -0,0 +1,53 @@
import { generateAccount } from '@actual-app/core/mocks';
import { describe, expect, it } from 'vitest';
import { getSyncSourceReadable, groupBankSyncAccounts } from './bankSyncUtils';
describe('bankSyncUtils', () => {
it('groups open accounts by provider and leaves unlinked last', () => {
const goCardlessAccount = generateAccount('GoCardless', true, false);
const pluggyAccount = {
...generateAccount('Pluggy', true, false),
account_sync_source: 'pluggyai' as const,
};
const simpleFinAccount = {
...generateAccount('SimpleFIN', true, false),
account_sync_source: 'simpleFin' as const,
};
const unlinkedAccount = generateAccount('Manual', false, false);
const closedAccount = {
...generateAccount('Closed', true, false),
closed: 1 as const,
};
const groupedAccounts = groupBankSyncAccounts([
unlinkedAccount,
simpleFinAccount,
closedAccount,
pluggyAccount,
goCardlessAccount,
]);
expect(Object.keys(groupedAccounts)).toEqual([
'goCardless',
'pluggyai',
'simpleFin',
'unlinked',
]);
expect(groupedAccounts.goCardless).toEqual([goCardlessAccount]);
expect(groupedAccounts.pluggyai).toEqual([pluggyAccount]);
expect(groupedAccounts.simpleFin).toEqual([simpleFinAccount]);
expect(groupedAccounts.unlinked).toEqual([unlinkedAccount]);
});
it('returns stable readable provider labels', () => {
const readable = getSyncSourceReadable(
(key: string) => `translated:${key}`,
);
expect(readable.goCardless).toBe('GoCardless');
expect(readable.simpleFin).toBe('SimpleFIN');
expect(readable.pluggyai).toBe('Pluggy.ai');
expect(readable.unlinked).toBe('translated:Unlinked');
});
});

View File

@@ -0,0 +1,85 @@
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
export type SyncProviders = BankSyncProviders | 'unlinked';
export type GroupedBankSyncAccounts = Partial<
Record<SyncProviders, AccountEntity[]>
>;
export const BUILT_IN_BANK_SYNC_PROVIDERS = [
'goCardless',
'simpleFin',
'pluggyai',
] as const satisfies BankSyncProviders[];
const SYNC_PROVIDER_KEYS = [
...BUILT_IN_BANK_SYNC_PROVIDERS,
'unlinked',
] as const satisfies readonly SyncProviders[];
const syncProviderKeysSet = new Set<string>(SYNC_PROVIDER_KEYS);
function isSyncProvider(value: string): value is SyncProviders {
return syncProviderKeysSet.has(value);
}
export function getSyncSourceReadable(
translate: (key: string) => string,
): Record<SyncProviders, string> {
return {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: translate('Unlinked'),
};
}
export function groupBankSyncAccounts(
accounts: AccountEntity[],
): GroupedBankSyncAccounts {
const groupedAccounts: GroupedBankSyncAccounts = {};
for (const account of accounts) {
if (account.closed) {
continue;
}
const syncSource = account.account_sync_source ?? 'unlinked';
const existingAccounts = groupedAccounts[syncSource];
if (existingAccounts) {
existingAccounts.push(account);
} else {
groupedAccounts[syncSource] = [account];
}
}
const sortedEntries = Object.entries(groupedAccounts)
.filter(
(entry): entry is [SyncProviders, AccountEntity[]] =>
isSyncProvider(entry[0]) && entry[1] != null,
)
.sort(([keyA], [keyB]) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
const sortedAccounts: GroupedBankSyncAccounts = {};
for (const [syncSource, providerAccounts] of sortedEntries) {
sortedAccounts[syncSource] = providerAccounts;
}
return sortedAccounts;
}
export function getGroupedBankSyncEntries(
groupedAccounts: GroupedBankSyncAccounts,
): Array<[SyncProviders, AccountEntity[]]> {
return Object.entries(groupedAccounts).filter(
(entry): entry is [SyncProviders, AccountEntity[]] =>
isSyncProvider(entry[0]) && entry[1] != null,
);
}

View File

@@ -5,10 +5,7 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
import type { AccountEntity } from '@actual-app/core/types/models';
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
import { Page } from '#components/Page';
@@ -19,63 +16,44 @@ import { useDispatch } from '#redux';
import { AccountsHeader } from './AccountsHeader';
import { AccountsList } from './AccountsList';
type SyncProviders = BankSyncProviders | 'unlinked';
const useSyncSourceReadable = () => {
const { t } = useTranslation();
const syncSourceReadable: Record<SyncProviders, string> = {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: t('Unlinked'),
};
return { syncSourceReadable };
};
import {
getGroupedBankSyncEntries,
getSyncSourceReadable,
groupBankSyncAccounts,
} from './bankSyncUtils';
import { BuiltInProviders } from './BuiltInProviders';
import { useBuiltInBankSyncProviders } from './useBuiltInBankSyncProviders';
export function BankSync() {
const { t } = useTranslation();
const [floatingSidebar] = useGlobalPref('floatingSidebar');
const { syncSourceReadable } = useSyncSourceReadable();
const { data: accounts = [] } = useAccounts();
const dispatch = useDispatch();
const { isNarrowWidth } = useResponsive();
const syncSourceReadable = useMemo(() => getSyncSourceReadable(t), [t]);
const {
providers,
syncServerStatus,
showPermissionWarning,
providersNeedingConfiguration,
} = useBuiltInBankSyncProviders();
const [hoveredAccount, setHoveredAccount] = useState<
AccountEntity['id'] | null
>(null);
const groupedAccounts = useMemo(() => {
const unsorted = accounts
.filter(a => !a.closed)
.reduce(
(acc, a) => {
const syncSource = a.account_sync_source ?? 'unlinked';
acc[syncSource] = acc[syncSource] || [];
acc[syncSource].push(a);
return acc;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
return sortedKeys.reduce(
(sorted, key) => {
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
return sorted;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
}, [accounts]);
const groupedAccounts = useMemo(
() => groupBankSyncAccounts(accounts),
[accounts],
);
const groupedAccountEntries = useMemo(
() => getGroupedBankSyncEntries(groupedAccounts),
[groupedAccounts],
);
const openAccounts = useMemo(
() => accounts.filter(account => !account.closed),
[accounts],
);
const onAction = async (account: AccountEntity, action: 'link' | 'edit') => {
switch (action) {
@@ -119,22 +97,30 @@ export function BankSync() {
paddingBottom: MOBILE_NAV_HEIGHT,
}}
>
<View style={{ marginTop: '1em' }}>
{accounts.length === 0 && (
<View style={{ marginTop: '1em', gap: 24 }}>
<BuiltInProviders
providers={providers}
syncServerStatus={syncServerStatus}
showPermissionWarning={showPermissionWarning}
providersNeedingConfiguration={providersNeedingConfiguration}
/>
{openAccounts.length === 0 && (
<Text style={{ fontSize: '1.1rem' }}>
<Trans>
To use the bank syncing features, you must first add an account.
</Trans>
</Text>
)}
{Object.entries(groupedAccounts).map(([syncProvider, accounts]) => {
{groupedAccountEntries.map(([syncProvider, accounts]) => {
return (
<View key={syncProvider} style={{ minHeight: 'initial' }}>
{Object.keys(groupedAccounts).length > 1 && (
{groupedAccountEntries.length > 1 && (
<Text
style={{ fontWeight: 500, fontSize: 20, margin: '.5em 0' }}
>
{syncSourceReadable[syncProvider as SyncProviders]}
{syncSourceReadable[syncProvider]}
</Text>
)}
<View style={styles.tableContainer}>

View File

@@ -0,0 +1,475 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { send } from '@actual-app/core/platform/client/connection';
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
import type { SyncServerSimpleFinAccount } from '@actual-app/core/types/models/simplefin';
import { useAuth } from '#auth/AuthProvider';
import { Permissions } from '#auth/types';
import { useMultiuserEnabled } from '#components/ServerContext';
import { authorizeBank } from '#gocardless';
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
import { useSyncServerStatus } from '#hooks/useSyncServerStatus';
import { pushModal } from '#modals/modalsSlice';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
import { BUILT_IN_BANK_SYNC_PROVIDERS } from './bankSyncUtils';
type ProviderAction = () => void | Promise<void>;
type SimpleFinAccount = {
id: string;
name: string;
balance: number;
org: {
name: string;
domain: string;
id: string;
};
};
type PluggyAiAccount = {
id: string;
name: string;
type: 'BANK' | string;
taxNumber: string;
owner: string;
balance: number;
bankData: {
automaticallyInvestedBalance: number;
closingBalance: number;
};
};
export type BuiltInBankSyncProviderState = {
id: BankSyncProviders;
displayName: string;
description: string;
isConfigured: boolean;
canConfigure: boolean;
isLoading?: boolean;
onConfigure: ProviderAction;
onLink: ProviderAction;
onReset: ProviderAction;
};
type SecretSetResponse = {
error?: string;
error_code?: string;
reason?: string;
};
type UseBuiltInBankSyncProvidersOptions = {
upgradingAccountId?: AccountEntity['id'];
};
async function ensureSuccessResponse(
response: SecretSetResponse,
fallbackMessage: string,
) {
if (response.error_code) {
throw new Error(response.reason || response.error_code);
}
if (response.error) {
throw new Error(response.reason || response.error || fallbackMessage);
}
}
export function useBuiltInBankSyncProviders({
upgradingAccountId,
}: UseBuiltInBankSyncProvidersOptions = {}) {
const { t } = useTranslation();
const dispatch = useDispatch();
const syncServerStatus = useSyncServerStatus();
const { hasPermission } = useAuth();
const multiuserEnabled = useMultiuserEnabled();
const canConfigureProviders =
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
boolean | null
>(null);
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState<
boolean | null
>(null);
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
boolean | null
>(null);
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
useState(false);
const { configuredGoCardless } = useGoCardlessStatus();
const { configuredSimpleFin } = useSimpleFinStatus();
const { configuredPluggyAi } = usePluggyAiStatus();
useEffect(() => {
setIsGoCardlessSetupComplete(configuredGoCardless);
}, [configuredGoCardless]);
useEffect(() => {
setIsSimpleFinSetupComplete(configuredSimpleFin);
}, [configuredSimpleFin]);
useEffect(() => {
setIsPluggyAiSetupComplete(configuredPluggyAi);
}, [configuredPluggyAi]);
const onGoCardlessInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'gocardless-init',
options: {
onSuccess: () => setIsGoCardlessSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const onSimpleFinInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const onPluggyAiInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'pluggyai-init',
options: {
onSuccess: () => setIsPluggyAiSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const notifyResetFailure = useCallback(
(providerName: string, error: unknown) => {
dispatch(
addNotification({
notification: {
type: 'error',
title: t('Failed to reset {{provider}}', {
provider: providerName,
}),
message: error instanceof Error ? error.message : String(error),
timeout: 5000,
},
}),
);
},
[dispatch, t],
);
const onGoCardlessReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'gocardless_secretId',
value: null,
}),
'Failed to clear GoCardless secret ID',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'gocardless_secretKey',
value: null,
}),
'Failed to clear GoCardless secret key',
);
setIsGoCardlessSetupComplete(false);
} catch (error) {
notifyResetFailure('GoCardless', error);
}
}, [notifyResetFailure]);
const onSimpleFinReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'simplefin_token',
value: null,
}),
'Failed to clear SimpleFIN token',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'simplefin_accessKey',
value: null,
}),
'Failed to clear SimpleFIN access key',
);
setIsSimpleFinSetupComplete(false);
} catch (error) {
notifyResetFailure('SimpleFIN', error);
}
}, [notifyResetFailure]);
const onPluggyAiReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'pluggyai_clientId',
value: null,
}),
'Failed to clear Pluggy.ai client ID',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'pluggyai_clientSecret',
value: null,
}),
'Failed to clear Pluggy.ai client secret',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'pluggyai_itemIds',
value: null,
}),
'Failed to clear Pluggy.ai item IDs',
);
setIsPluggyAiSetupComplete(false);
} catch (error) {
notifyResetFailure('Pluggy.ai', error);
}
}, [notifyResetFailure]);
const onConnectGoCardless = useCallback(() => {
if (!isGoCardlessSetupComplete) {
onGoCardlessInit();
return;
}
void authorizeBank(dispatch, upgradingAccountId);
}, [
dispatch,
isGoCardlessSetupComplete,
onGoCardlessInit,
upgradingAccountId,
]);
const onConnectSimpleFin = useCallback(async () => {
if (!isSimpleFinSetupComplete) {
onSimpleFinInit();
return;
}
if (loadingSimpleFinAccounts) {
return;
}
setLoadingSimpleFinAccounts(true);
try {
const results = await send('simplefin-accounts');
if (results.error_code) {
throw new Error(results.reason);
}
if ('error' in results && results.error) {
throw new Error(results.reason || results.error);
}
const externalAccounts: SyncServerSimpleFinAccount[] = (
(results.accounts ?? []) as SimpleFinAccount[]
).map(oldAccount => ({
account_id: oldAccount.id,
name: oldAccount.name,
institution: oldAccount.org.name,
orgDomain: oldAccount.org.domain,
orgId: oldAccount.org.id,
balance: oldAccount.balance,
}));
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts,
syncSource: 'simpleFin',
upgradingAccountId,
},
},
}),
);
} catch {
onSimpleFinInit();
} finally {
setLoadingSimpleFinAccounts(false);
}
}, [
dispatch,
isSimpleFinSetupComplete,
loadingSimpleFinAccounts,
onSimpleFinInit,
upgradingAccountId,
]);
const onConnectPluggyAi = useCallback(async () => {
if (!isPluggyAiSetupComplete) {
onPluggyAiInit();
return;
}
try {
const results = await send('pluggyai-accounts');
if (results.error_code) {
throw new Error(results.reason);
}
if ('error' in results) {
throw new Error(results.error);
}
const externalAccounts = (results.accounts as PluggyAiAccount[]).map(
oldAccount => ({
account_id: oldAccount.id,
name: `${oldAccount.name.trim()} - ${
oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner
}`,
institution: oldAccount.name,
orgDomain: null,
orgId: oldAccount.id,
balance:
oldAccount.type === 'BANK'
? oldAccount.bankData.automaticallyInvestedBalance +
oldAccount.bankData.closingBalance
: oldAccount.balance,
}),
);
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts,
syncSource: 'pluggyai',
upgradingAccountId,
},
},
}),
);
} catch (error) {
dispatch(
addNotification({
notification: {
type: 'error',
title: t('Error when trying to contact Pluggy.ai'),
message: error instanceof Error ? error.message : String(error),
timeout: 5000,
},
}),
);
onPluggyAiInit();
}
}, [
dispatch,
isPluggyAiSetupComplete,
onPluggyAiInit,
t,
upgradingAccountId,
]);
const configuredProviders = {
goCardless: Boolean(isGoCardlessSetupComplete),
simpleFin: Boolean(isSimpleFinSetupComplete),
pluggyai: Boolean(isPluggyAiSetupComplete),
} satisfies Record<BankSyncProviders, boolean>;
const providers = useMemo<BuiltInBankSyncProviderState[]>(
() =>
BUILT_IN_BANK_SYNC_PROVIDERS.map(providerId => {
if (providerId === 'goCardless') {
return {
id: providerId,
displayName: 'GoCardless',
description: t(
'Link a European bank account to automatically download transactions.',
),
isConfigured: configuredProviders.goCardless,
canConfigure: canConfigureProviders,
onConfigure: onGoCardlessInit,
onLink: onConnectGoCardless,
onReset: onGoCardlessReset,
};
}
if (providerId === 'simpleFin') {
return {
id: providerId,
displayName: 'SimpleFIN',
description: t(
'Link a North American bank account to automatically download transactions.',
),
isConfigured: configuredProviders.simpleFin,
canConfigure: canConfigureProviders,
isLoading: loadingSimpleFinAccounts,
onConfigure: onSimpleFinInit,
onLink: onConnectSimpleFin,
onReset: onSimpleFinReset,
};
}
return {
id: providerId,
displayName: 'Pluggy.ai',
description: t(
'Link a Brazilian bank account to automatically download transactions.',
),
isConfigured: configuredProviders.pluggyai,
canConfigure: canConfigureProviders,
onConfigure: onPluggyAiInit,
onLink: onConnectPluggyAi,
onReset: onPluggyAiReset,
};
}),
[
canConfigureProviders,
configuredProviders.goCardless,
configuredProviders.pluggyai,
configuredProviders.simpleFin,
loadingSimpleFinAccounts,
onConnectGoCardless,
onConnectPluggyAi,
onConnectSimpleFin,
onGoCardlessInit,
onGoCardlessReset,
onPluggyAiInit,
onPluggyAiReset,
onSimpleFinInit,
onSimpleFinReset,
t,
],
);
const providersNeedingConfiguration = providers.filter(
provider => !provider.isConfigured,
);
return {
providers,
syncServerStatus,
canConfigureProviders,
showPermissionWarning:
providersNeedingConfiguration.length > 0 && !canConfigureProviders,
providersNeedingConfiguration,
};
}

View File

@@ -5,14 +5,17 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { AccountEntity } from '@actual-app/core/types/models';
import type {
GroupedBankSyncAccounts,
SyncProviders,
} from '#components/banksync/bankSyncUtils';
import { getGroupedBankSyncEntries } from '#components/banksync/bankSyncUtils';
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
import { BankSyncAccountsListItem } from './BankSyncAccountsListItem';
type SyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai' | 'unlinked';
type BankSyncAccountsListProps = {
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
groupedAccounts: GroupedBankSyncAccounts;
syncSourceReadable: Record<SyncProviders, string>;
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
};
@@ -22,7 +25,8 @@ export function BankSyncAccountsList({
syncSourceReadable,
onAction,
}: BankSyncAccountsListProps) {
const allAccounts = Object.values(groupedAccounts).flat();
const groupedAccountEntries = getGroupedBankSyncEntries(groupedAccounts);
const allAccounts = groupedAccountEntries.flatMap(([, accounts]) => accounts);
if (allAccounts.length === 0) {
return (
@@ -47,15 +51,13 @@ export function BankSyncAccountsList({
);
}
const shouldShowProviderHeaders = Object.keys(groupedAccounts).length > 1;
const shouldShowProviderHeaders = groupedAccountEntries.length > 1;
return (
<div
style={{ flex: 1, overflow: 'auto', paddingBottom: MOBILE_NAV_HEIGHT }}
>
{(
Object.entries(groupedAccounts) as [SyncProviders, AccountEntity[]][]
).map(([provider, accounts]) => (
{groupedAccountEntries.map(([provider, accounts]) => (
<div key={provider}>
{shouldShowProviderHeaders && (
<div

View File

@@ -5,11 +5,14 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
import type { AccountEntity } from '@actual-app/core/types/models';
import {
getGroupedBankSyncEntries,
getSyncSourceReadable,
groupBankSyncAccounts,
} from '#components/banksync/bankSyncUtils';
import type { GroupedBankSyncAccounts } from '#components/banksync/bankSyncUtils';
import { Search } from '#components/common/Search';
import { MobilePageHeader, Page } from '#components/Page';
import { useAccounts } from '#hooks/useAccounts';
@@ -19,79 +22,42 @@ import { useDispatch } from '#redux';
import { BankSyncAccountsList } from './BankSyncAccountsList';
type SyncProviders = BankSyncProviders | 'unlinked';
const useSyncSourceReadable = () => {
const { t } = useTranslation();
const syncSourceReadable: Record<SyncProviders, string> = {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: t('Unlinked'),
};
return { syncSourceReadable };
};
export function MobileBankSyncPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { syncSourceReadable } = useSyncSourceReadable();
const { data: accounts = [] } = useAccounts();
const [filter, setFilter] = useState('');
const syncSourceReadable = useMemo(() => getSyncSourceReadable(t), [t]);
const openAccounts = useMemo(
() => accounts.filter(a => !a.closed),
[accounts],
);
const groupedAccounts = useMemo(() => {
const unsorted = openAccounts.reduce(
(acc, a) => {
const syncSource = a.account_sync_source ?? 'unlinked';
acc[syncSource] = acc[syncSource] || [];
acc[syncSource].push(a);
return acc;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
return sortedKeys.reduce(
(sorted, key) => {
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
return sorted;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
}, [openAccounts]);
const groupedAccounts = useMemo(
() => groupBankSyncAccounts(openAccounts),
[openAccounts],
);
const filteredGroupedAccounts = useMemo(() => {
if (!filter) return groupedAccounts;
const filterLower = filter.toLowerCase();
const filtered: Record<SyncProviders, AccountEntity[]> = {} as Record<
SyncProviders,
AccountEntity[]
>;
const filtered: GroupedBankSyncAccounts = {};
Object.entries(groupedAccounts).forEach(([provider, accounts]) => {
const filteredAccounts = accounts.filter(
account =>
account.name.toLowerCase().includes(filterLower) ||
account.bankName?.toLowerCase().includes(filterLower),
);
if (filteredAccounts.length > 0) {
filtered[provider as SyncProviders] = filteredAccounts;
}
});
getGroupedBankSyncEntries(groupedAccounts).forEach(
([provider, accounts]) => {
const filteredAccounts = accounts.filter(
account =>
account.name.toLowerCase().includes(filterLower) ||
account.bankName?.toLowerCase().includes(filterLower),
);
if (filteredAccounts.length > 0) {
filtered[provider] = filteredAccounts;
}
},
);
return filtered;
}, [groupedAccounts, filter]);

View File

@@ -1,32 +1,20 @@
import React, { useEffect, useState } from 'react';
import { Dialog, DialogTrigger } from 'react-aria-components';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button, ButtonWithLoading } from '@actual-app/components/button';
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
import { Button } from '@actual-app/components/button';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { Menu } from '@actual-app/components/menu';
import { Paragraph } from '@actual-app/components/paragraph';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import { useAuth } from '#auth/AuthProvider';
import { Permissions } from '#auth/types';
import { Warning } from '#components/alerts';
import { BuiltInProviders } from '#components/banksync/BuiltInProviders';
import { useBuiltInBankSyncProviders } from '#components/banksync/useBuiltInBankSyncProviders';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { useMultiuserEnabled } from '#components/ServerContext';
import { authorizeBank } from '#gocardless';
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
import { useSyncServerStatus } from '#hooks/useSyncServerStatus';
import { useNavigate } from '#hooks/useNavigate';
import { pushModal } from '#modals/modalsSlice';
import type { Modal as ModalType } from '#modals/modalsSlice';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
type CreateAccountModalProps = Extract<
@@ -38,296 +26,25 @@ export function CreateAccountModal({
upgradingAccountId,
}: CreateAccountModalProps) {
const { t } = useTranslation();
const syncServerStatus = useSyncServerStatus();
const dispatch = useDispatch();
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
boolean | null
>(null);
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState<
boolean | null
>(null);
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
boolean | null
>(null);
const { hasPermission } = useAuth();
const multiuserEnabled = useMultiuserEnabled();
const onConnectGoCardless = () => {
if (!isGoCardlessSetupComplete) {
onGoCardlessInit();
return;
}
if (upgradingAccountId == null) {
void authorizeBank(dispatch);
} else {
void authorizeBank(dispatch);
}
};
const onConnectSimpleFin = async () => {
if (!isSimpleFinSetupComplete) {
onSimpleFinInit();
return;
}
if (loadingSimpleFinAccounts) {
return;
}
setLoadingSimpleFinAccounts(true);
try {
const results = await send('simplefin-accounts');
if (results.error_code) {
throw new Error(results.reason);
}
const newAccounts = [];
type NormalizedAccount = {
account_id: string;
name: string;
institution: string;
orgDomain: string;
orgId: string;
balance: number;
};
for (const oldAccount of results.accounts ?? []) {
const newAccount: NormalizedAccount = {
account_id: oldAccount.id,
name: oldAccount.name,
institution: oldAccount.org.name,
orgDomain: oldAccount.org.domain,
orgId: oldAccount.org.id,
balance: oldAccount.balance,
};
newAccounts.push(newAccount);
}
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts: newAccounts,
syncSource: 'simpleFin',
},
},
}),
);
} catch (err) {
console.error(err);
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
}
setLoadingSimpleFinAccounts(false);
};
const onConnectPluggyAi = async () => {
if (!isPluggyAiSetupComplete) {
onPluggyAiInit();
return;
}
try {
const results = await send('pluggyai-accounts');
if (results.error_code) {
throw new Error(results.reason);
} else if ('error' in results) {
throw new Error(results.error);
}
const newAccounts = [];
type NormalizedAccount = {
account_id: string;
name: string;
institution: string;
orgDomain: string | null;
orgId: string;
balance: number;
};
for (const oldAccount of results.accounts) {
const newAccount: NormalizedAccount = {
account_id: oldAccount.id,
name: `${oldAccount.name.trim()} - ${oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner}`,
institution: oldAccount.name,
orgDomain: null,
orgId: oldAccount.id,
balance:
oldAccount.type === 'BANK'
? oldAccount.bankData.automaticallyInvestedBalance +
oldAccount.bankData.closingBalance
: oldAccount.balance,
};
newAccounts.push(newAccount);
}
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts: newAccounts,
syncSource: 'pluggyai',
},
},
}),
);
} catch (err) {
console.error(err);
addNotification({
notification: {
type: 'error',
title: t('Error when trying to contact Pluggy.ai'),
message: (err as Error).message,
timeout: 5000,
},
});
dispatch(
pushModal({
modal: {
name: 'pluggyai-init',
options: {
onSuccess: () => setIsPluggyAiSetupComplete(true),
},
},
}),
);
}
};
const onGoCardlessInit = () => {
dispatch(
pushModal({
modal: {
name: 'gocardless-init',
options: {
onSuccess: () => setIsGoCardlessSetupComplete(true),
},
},
}),
);
};
const onSimpleFinInit = () => {
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
};
const onPluggyAiInit = () => {
dispatch(
pushModal({
modal: {
name: 'pluggyai-init',
options: {
onSuccess: () => setIsPluggyAiSetupComplete(true),
},
},
}),
);
};
const onGoCardlessReset = () => {
void send('secret-set', {
name: 'gocardless_secretId',
value: null,
}).then(() => {
void send('secret-set', {
name: 'gocardless_secretKey',
value: null,
}).then(() => {
setIsGoCardlessSetupComplete(false);
});
});
};
const onSimpleFinReset = () => {
void send('secret-set', {
name: 'simplefin_token',
value: null,
}).then(() => {
void send('secret-set', {
name: 'simplefin_accessKey',
value: null,
}).then(() => {
setIsSimpleFinSetupComplete(false);
});
});
};
const onPluggyAiReset = () => {
void send('secret-set', {
name: 'pluggyai_clientId',
value: null,
}).then(() => {
void send('secret-set', {
name: 'pluggyai_clientSecret',
value: null,
}).then(() => {
void send('secret-set', {
name: 'pluggyai_itemIds',
value: null,
}).then(() => {
setIsPluggyAiSetupComplete(false);
});
});
});
};
const navigate = useNavigate();
const {
providers,
syncServerStatus,
showPermissionWarning,
providersNeedingConfiguration,
} = useBuiltInBankSyncProviders({ upgradingAccountId });
const onCreateLocalAccount = () => {
dispatch(pushModal({ modal: { name: 'add-local-account' } }));
};
const { configuredGoCardless } = useGoCardlessStatus();
useEffect(() => {
setIsGoCardlessSetupComplete(configuredGoCardless);
}, [configuredGoCardless]);
const { configuredSimpleFin } = useSimpleFinStatus();
useEffect(() => {
setIsSimpleFinSetupComplete(configuredSimpleFin);
}, [configuredSimpleFin]);
const { configuredPluggyAi } = usePluggyAiStatus();
useEffect(() => {
setIsPluggyAiSetupComplete(configuredPluggyAi);
}, [configuredPluggyAi]);
let title = t('Add account');
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
useState(false);
if (upgradingAccountId != null) {
title = t('Link account');
}
const canSetSecrets =
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
return (
<Modal name="add-account">
{({ state }) => (
@@ -336,266 +53,69 @@ export function CreateAccountModal({
title={title}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
{upgradingAccountId == null && (
<View style={{ gap: 10 }}>
<InitialFocus>
<Button
variant="primary"
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
}}
onPress={onCreateLocalAccount}
>
<Trans>Create a local account</Trans>
</Button>
</InitialFocus>
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Text>
<Trans>
<strong>Create a local account</strong> if you want to add
transactions manually. You can also{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/transactions/importing"
linkColor="muted"
>
import QIF/OFX/QFX files into a local account
</Link>
.
</Trans>
</Text>
</View>
</View>
)}
<View style={{ gap: 10 }}>
{syncServerStatus === 'online' ? (
<>
{canSetSecrets && (
<>
<View
style={{
flexDirection: 'row',
gap: 10,
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectGoCardless}
<View
style={{
maxWidth: upgradingAccountId == null ? 500 : 720,
gap: 24,
color: theme.pageText,
}}
>
{upgradingAccountId != null ? (
<>
<Paragraph
style={{ fontSize: 15, color: theme.pageTextSubdued }}
>
<Trans>
Choose a bank sync provider to connect this account.
</Trans>
</Paragraph>
<BuiltInProviders
providers={providers}
syncServerStatus={syncServerStatus}
showPermissionWarning={showPermissionWarning}
providersNeedingConfiguration={providersNeedingConfiguration}
/>
</>
) : (
<>
<View style={{ gap: 10 }}>
<InitialFocus>
<Button
variant="primary"
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
}}
onPress={onCreateLocalAccount}
>
<Trans>Create a local account</Trans>
</Button>
</InitialFocus>
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Text>
<Trans>
<strong>Create a local account</strong> if you want to
add transactions manually. You can also{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/transactions/importing"
linkColor="muted"
>
{isGoCardlessSetupComplete
? t('Link bank account with GoCardless')
: t('Set up GoCardless for bank sync')}
</ButtonWithLoading>
{isGoCardlessSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('GoCardless menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onGoCardlessReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset GoCardless credentials'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>European</em> bank account
</strong>{' '}
to automatically download transactions. GoCardless
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
<View
style={{
flexDirection: 'row',
gap: 10,
marginTop: '18px',
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
isLoading={loadingSimpleFinAccounts}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectSimpleFin}
>
{isSimpleFinSetupComplete
? t('Link bank account with SimpleFIN')
: t('Set up SimpleFIN for bank sync')}
</ButtonWithLoading>
{isSimpleFinSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('SimpleFIN menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onSimpleFinReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset SimpleFIN credentials'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>North American</em> bank account
</strong>{' '}
to automatically download transactions. SimpleFIN
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
<View
style={{
flexDirection: 'row',
gap: 10,
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectPluggyAi}
>
{isPluggyAiSetupComplete
? t('Link bank account with Pluggy.ai')
: t('Set up Pluggy.ai for bank sync')}
</ButtonWithLoading>
{isPluggyAiSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('Pluggy.ai menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onPluggyAiReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset Pluggy.ai credentials'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>Brazilian</em> bank account
</strong>{' '}
to automatically download transactions. Pluggy.ai
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
</>
)}
{(!isGoCardlessSetupComplete ||
!isSimpleFinSetupComplete ||
!isPluggyAiSetupComplete) &&
!canSetSecrets && (
<Warning>
<Trans>
You don&apos;t have the required permissions to set up
secrets. Please contact an Admin to configure
</Trans>{' '}
{[
isGoCardlessSetupComplete ? '' : 'GoCardless',
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
]
.filter(Boolean)
.join(' or ')}
import QIF/OFX/QFX files into a local account
</Link>
.
</Warning>
)}
</>
) : (
<>
</Trans>
</Text>
</View>
</View>
<View style={{ gap: 10 }}>
<Button
isDisabled
onPress={() => {
state.close();
void navigate('/bank-sync');
}}
style={{
padding: '10px 0',
fontSize: 15,
@@ -604,22 +124,17 @@ export function CreateAccountModal({
>
<Trans>Set up bank sync</Trans>
</Button>
<Paragraph style={{ fontSize: 15 }}>
<Paragraph
style={{ fontSize: 15, color: theme.pageTextSubdued }}
>
<Trans>
Connect to an Actual server to set up{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync"
linkColor="muted"
>
automatic syncing
</Link>
.
Configure providers and link accounts from the Bank Sync
page.
</Trans>
</Paragraph>
</>
)}
</View>
</View>
</>
)}
</View>
</>
)}

View File

@@ -74,22 +74,26 @@ export type SelectLinkedAccountsModalProps =
requisitionId: string;
externalAccounts: SyncServerGoCardlessAccount[];
syncSource: 'goCardless';
upgradingAccountId?: string;
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerSimpleFinAccount[];
syncSource: 'simpleFin';
upgradingAccountId?: string;
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerPluggyAiAccount[];
syncSource: 'pluggyai';
upgradingAccountId?: string;
};
export function SelectLinkedAccountsModal({
requisitionId = undefined,
externalAccounts,
syncSource,
upgradingAccountId,
}: SelectLinkedAccountsModalProps) {
const propsWithSortedExternalAccounts =
useMemo<SelectLinkedAccountsModalProps>(() => {
@@ -104,22 +108,25 @@ export function SelectLinkedAccountsModal({
return {
syncSource: 'simpleFin',
externalAccounts: toSort as SyncServerSimpleFinAccount[],
upgradingAccountId,
};
case 'pluggyai':
return {
syncSource: 'pluggyai',
externalAccounts: toSort as SyncServerPluggyAiAccount[],
upgradingAccountId,
};
case 'goCardless':
return {
syncSource: 'goCardless',
requisitionId: requisitionId!,
externalAccounts: toSort as SyncServerGoCardlessAccount[],
upgradingAccountId,
};
default:
throw new Error(`Unrecognized sync source: ${String(syncSource)}`);
}
}, [externalAccounts, syncSource, requisitionId]);
}, [externalAccounts, syncSource, requisitionId, upgradingAccountId]);
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
@@ -140,11 +147,27 @@ export function SelectLinkedAccountsModal({
});
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
() => {
return Object.fromEntries(
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;
}
return initiallyChosenAccounts;
},
);
const [customStartingDates, setCustomStartingDates] = useState<

View File

@@ -1,5 +1,8 @@
import { send } from '@actual-app/core/platform/client/connection';
import type { GoCardlessToken } from '@actual-app/core/types/models';
import type {
AccountEntity,
GoCardlessToken,
} from '@actual-app/core/types/models';
import { pushModal } from './modals/modalsSlice';
import type { AppDispatch } from './redux/store';
@@ -41,7 +44,10 @@ function _authorize(
);
}
export async function authorizeBank(dispatch: AppDispatch) {
export async function authorizeBank(
dispatch: AppDispatch,
upgradingAccountId?: AccountEntity['id'],
) {
_authorize(dispatch, {
onSuccess: async data => {
dispatch(
@@ -52,6 +58,7 @@ export async function authorizeBank(dispatch: AppDispatch) {
externalAccounts: data.accounts,
requisitionId: data.id,
syncSource: 'goCardless',
upgradingAccountId,
},
},
}),

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [leandro-menezes]
---
Redesign bank sync and add account flows around the new Bank Sync page.