mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 07:01:45 -05:00
Compare commits
18 Commits
matiss/crd
...
release/26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe7e48cf9 | ||
|
|
b42c48bed5 | ||
|
|
15b6b77a9f | ||
|
|
4c62e2a75d | ||
|
|
922f0b8a53 | ||
|
|
cfd527b446 | ||
|
|
56ea6cba68 | ||
|
|
60c0796a92 | ||
|
|
7b0460d7e9 | ||
|
|
46ba63f370 | ||
|
|
4926cd5d76 | ||
|
|
05efe9ceee | ||
|
|
da522f3e3e | ||
|
|
8636591ddf | ||
|
|
b5d59c7428 | ||
|
|
c02e308739 | ||
|
|
a5e80edd32 | ||
|
|
9beeae54e0 |
@@ -15,8 +15,7 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly",
|
||||
"__APP_VERSION__": "readonly"
|
||||
"FS": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// Import sorting
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspace @actual-app/web run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
|
||||
"rebuild-node": "yarn workspace @actual-app/core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
"!dist/**/*.spec.d.ts",
|
||||
"!dist/**/*.spec.d.ts.map"
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
@@ -21,9 +25,7 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build:node": "vite build",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"target": "ES2021",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 69 KiB |
@@ -34,9 +34,6 @@
|
||||
"#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",
|
||||
|
||||
@@ -335,17 +335,10 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
// Skip SW registration in dev so stale cached assets don't override edits
|
||||
// between page loads. Plugin code that needs a SW can register one itself.
|
||||
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
|
||||
// SW lifecycle to swap the page — fall back to a plain reload so callers
|
||||
// don't hang on the never-resolving promise inside applyAppUpdate.
|
||||
const updateSW = IS_DEV
|
||||
? () => window.location.reload()
|
||||
: registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
|
||||
@@ -243,8 +243,8 @@ function ServerSyncButton({ style, isMobile = false }: ServerSyncButtonProps) {
|
||||
) : (
|
||||
<AnimatedRefresh animating={syncing} />
|
||||
)}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : null}>
|
||||
{syncState === 'disabled' ? ` ${t('Disabled')}` : null}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
|
||||
{syncState === 'disabled' ? t('Disabled') : null}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,10 @@ 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 } from '@actual-app/core/types/models';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
|
||||
import { Page } from '#components/Page';
|
||||
@@ -16,44 +19,63 @@ import { useDispatch } from '#redux';
|
||||
|
||||
import { AccountsHeader } from './AccountsHeader';
|
||||
import { AccountsList } from './AccountsList';
|
||||
import {
|
||||
getGroupedBankSyncEntries,
|
||||
getSyncSourceReadable,
|
||||
groupBankSyncAccounts,
|
||||
} from './bankSyncUtils';
|
||||
import { BuiltInProviders } from './BuiltInProviders';
|
||||
import { useBuiltInBankSyncProviders } from './useBuiltInBankSyncProviders';
|
||||
|
||||
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 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(
|
||||
() => groupBankSyncAccounts(accounts),
|
||||
[accounts],
|
||||
);
|
||||
const groupedAccountEntries = useMemo(
|
||||
() => getGroupedBankSyncEntries(groupedAccounts),
|
||||
[groupedAccounts],
|
||||
);
|
||||
const openAccounts = useMemo(
|
||||
() => accounts.filter(account => !account.closed),
|
||||
[accounts],
|
||||
);
|
||||
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 onAction = async (account: AccountEntity, action: 'link' | 'edit') => {
|
||||
switch (action) {
|
||||
@@ -97,30 +119,22 @@ export function BankSync() {
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View style={{ marginTop: '1em', gap: 24 }}>
|
||||
<BuiltInProviders
|
||||
providers={providers}
|
||||
syncServerStatus={syncServerStatus}
|
||||
showPermissionWarning={showPermissionWarning}
|
||||
providersNeedingConfiguration={providersNeedingConfiguration}
|
||||
/>
|
||||
|
||||
{openAccounts.length === 0 && (
|
||||
<View style={{ marginTop: '1em' }}>
|
||||
{accounts.length === 0 && (
|
||||
<Text style={{ fontSize: '1.1rem' }}>
|
||||
<Trans>
|
||||
To use the bank syncing features, you must first add an account.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{groupedAccountEntries.map(([syncProvider, accounts]) => {
|
||||
{Object.entries(groupedAccounts).map(([syncProvider, accounts]) => {
|
||||
return (
|
||||
<View key={syncProvider} style={{ minHeight: 'initial' }}>
|
||||
{groupedAccountEntries.length > 1 && (
|
||||
{Object.keys(groupedAccounts).length > 1 && (
|
||||
<Text
|
||||
style={{ fontWeight: 500, fontSize: 20, margin: '.5em 0' }}
|
||||
>
|
||||
{syncSourceReadable[syncProvider]}
|
||||
{syncSourceReadable[syncProvider as SyncProviders]}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.tableContainer}>
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -512,10 +512,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
placement="bottom end"
|
||||
isOpen={balanceMenuOpen}
|
||||
onOpenChange={() => setBalanceMenuOpen(false)}
|
||||
style={{
|
||||
margin: 1,
|
||||
minWidth: 190,
|
||||
}}
|
||||
style={{ margin: 1 }}
|
||||
isNonModal
|
||||
{...balancePosition}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useBudgetAutomationCategories() {
|
||||
const { t } = useTranslation();
|
||||
const { data: { grouped } = { grouped: [] } } = useCategories();
|
||||
const categories = useMemo(() => {
|
||||
const incomeGroups = grouped.filter(group => group.is_income);
|
||||
const incomeGroup = grouped.filter(group => group.name === 'Income')[0];
|
||||
return [
|
||||
{
|
||||
id: '',
|
||||
@@ -21,10 +21,7 @@ export function useBudgetAutomationCategories() {
|
||||
},
|
||||
],
|
||||
},
|
||||
...incomeGroups.map(group => ({
|
||||
...group,
|
||||
name: t('Income categories'),
|
||||
})),
|
||||
{ ...incomeGroup, name: t('Income categories') },
|
||||
];
|
||||
}, [grouped, t]);
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validatePercentageAllocation } from './validateAutomation';
|
||||
|
||||
function percent(
|
||||
category: string,
|
||||
percent: number,
|
||||
previous = false,
|
||||
): Template {
|
||||
return {
|
||||
type: 'percentage',
|
||||
percent,
|
||||
previous,
|
||||
category,
|
||||
directive: 'template',
|
||||
priority: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('validatePercentageAllocation', () => {
|
||||
it('returns null when no percentage templates are present', () => {
|
||||
expect(validatePercentageAllocation([])).toBeNull();
|
||||
});
|
||||
|
||||
it('flags a single source over 100%', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Salary', 60),
|
||||
percent('Salary', 50),
|
||||
]),
|
||||
).toEqual({ kind: 'percent-over-100', total: 110 });
|
||||
});
|
||||
|
||||
it('does not sum across distinct income sources', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Income-HSA', 100, true),
|
||||
percent('Interest-HSA', 100),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats this-month and last-month income as different sources', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Salary', 100, false),
|
||||
percent('Salary', 100, true),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores templates with a missing source', () => {
|
||||
const orphan = {
|
||||
...percent('Salary', 100),
|
||||
category: null as unknown as string,
|
||||
};
|
||||
expect(validatePercentageAllocation([orphan])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches sources case-insensitively', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Salary', 60),
|
||||
percent('salary', 50),
|
||||
]),
|
||||
).toEqual({ kind: 'percent-over-100', total: 110 });
|
||||
});
|
||||
});
|
||||
@@ -93,18 +93,3 @@ export function validateAutomation(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePercentageAllocation(
|
||||
templates: readonly Template[],
|
||||
): GlobalConflictKind | null {
|
||||
const percentBySource = new Map<string, number>();
|
||||
for (const t of templates) {
|
||||
if (t.type !== 'percentage' || !t.category) continue;
|
||||
const key = `${t.previous}|${t.category.toLocaleLowerCase()}`;
|
||||
percentBySource.set(key, (percentBySource.get(key) ?? 0) + t.percent);
|
||||
}
|
||||
const maxPercent = Math.max(0, ...percentBySource.values());
|
||||
return maxPercent > 100
|
||||
? { kind: 'percent-over-100', total: maxPercent }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -5,17 +5,14 @@ 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: GroupedBankSyncAccounts;
|
||||
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
|
||||
syncSourceReadable: Record<SyncProviders, string>;
|
||||
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
|
||||
};
|
||||
@@ -25,8 +22,7 @@ export function BankSyncAccountsList({
|
||||
syncSourceReadable,
|
||||
onAction,
|
||||
}: BankSyncAccountsListProps) {
|
||||
const groupedAccountEntries = getGroupedBankSyncEntries(groupedAccounts);
|
||||
const allAccounts = groupedAccountEntries.flatMap(([, accounts]) => accounts);
|
||||
const allAccounts = Object.values(groupedAccounts).flat();
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
return (
|
||||
@@ -51,13 +47,15 @@ export function BankSyncAccountsList({
|
||||
);
|
||||
}
|
||||
|
||||
const shouldShowProviderHeaders = groupedAccountEntries.length > 1;
|
||||
const shouldShowProviderHeaders = Object.keys(groupedAccounts).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ flex: 1, overflow: 'auto', paddingBottom: MOBILE_NAV_HEIGHT }}
|
||||
>
|
||||
{groupedAccountEntries.map(([provider, accounts]) => (
|
||||
{(
|
||||
Object.entries(groupedAccounts) as [SyncProviders, AccountEntity[]][]
|
||||
).map(([provider, accounts]) => (
|
||||
<div key={provider}>
|
||||
{shouldShowProviderHeaders && (
|
||||
<div
|
||||
|
||||
@@ -5,14 +5,11 @@ 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 } from '@actual-app/core/types/models';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} 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';
|
||||
@@ -22,42 +19,79 @@ 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(
|
||||
() => groupBankSyncAccounts(openAccounts),
|
||||
[openAccounts],
|
||||
);
|
||||
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 filteredGroupedAccounts = useMemo(() => {
|
||||
if (!filter) return groupedAccounts;
|
||||
|
||||
const filterLower = filter.toLowerCase();
|
||||
const filtered: GroupedBankSyncAccounts = {};
|
||||
const filtered: Record<SyncProviders, AccountEntity[]> = {} as Record<
|
||||
SyncProviders,
|
||||
AccountEntity[]
|
||||
>;
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [groupedAccounts, filter]);
|
||||
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
} from '#components/budget/goals/automationExamples';
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
|
||||
import {
|
||||
validateAutomation,
|
||||
validatePercentageAllocation,
|
||||
} from '#components/budget/goals/validateAutomation';
|
||||
import { validateAutomation } from '#components/budget/goals/validateAutomation';
|
||||
import type { GlobalConflictKind } from '#components/budget/goals/validateAutomation';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { useLocale } from '#hooks/useLocale';
|
||||
@@ -214,7 +212,12 @@ export function BudgetAutomationsBody({
|
||||
dryRun?.perTemplate?.[i] != null ? dryRun.perTemplate[i] : null,
|
||||
);
|
||||
const hasErrors = automationErrors.some(error => error !== null);
|
||||
const conflict = validatePercentageAllocation(templates);
|
||||
const percentSum = templates.reduce<number>((sum, t) => {
|
||||
if (t.type === 'percentage') return sum + t.percent;
|
||||
return sum;
|
||||
}, 0);
|
||||
const conflict: GlobalConflictKind | null =
|
||||
percentSum > 100 ? { kind: 'percent-over-100', total: percentSum } : null;
|
||||
|
||||
const categoryNameMap: Record<string, string> = {};
|
||||
for (const group of categories) {
|
||||
|
||||
@@ -65,20 +65,9 @@ export function BudgetAutomationsModal({
|
||||
hasSpendTemplate ||
|
||||
hasCleanupDirective;
|
||||
|
||||
const incomeNameToId = new Map<string, string>();
|
||||
for (const group of categories) {
|
||||
for (const cat of group.categories ?? []) {
|
||||
if (cat.name) incomeNameToId.set(cat.name.toLowerCase(), cat.id);
|
||||
}
|
||||
}
|
||||
const resolved = parsedTemplates?.map(t => {
|
||||
if (t.type !== 'percentage' || !t.category) return t;
|
||||
const id = incomeNameToId.get(t.category.toLowerCase());
|
||||
return id ? { ...t, category: id } : t;
|
||||
});
|
||||
const initialEntries =
|
||||
resolved && !hasUnsupportedDirective
|
||||
? migrateTemplatesToAutomations(resolved)
|
||||
parsedTemplates && !hasUnsupportedDirective
|
||||
? migrateTemplatesToAutomations(parsedTemplates)
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
||||
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 { BuiltInProviders } from '#components/banksync/BuiltInProviders';
|
||||
import { useBuiltInBankSyncProviders } from '#components/banksync/useBuiltInBankSyncProviders';
|
||||
import { useAuth } from '#auth/AuthProvider';
|
||||
import { Permissions } from '#auth/types';
|
||||
import { Warning } from '#components/alerts';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
|
||||
import { useNavigate } from '#hooks/useNavigate';
|
||||
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 type { Modal as ModalType } from '#modals/modalsSlice';
|
||||
import { addNotification } from '#notifications/notificationsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
type CreateAccountModalProps = Extract<
|
||||
@@ -26,25 +38,296 @@ export function CreateAccountModal({
|
||||
upgradingAccountId,
|
||||
}: CreateAccountModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
providers,
|
||||
syncServerStatus,
|
||||
showPermissionWarning,
|
||||
providersNeedingConfiguration,
|
||||
} = useBuiltInBankSyncProviders({ upgradingAccountId });
|
||||
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 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 }) => (
|
||||
@@ -53,69 +336,266 @@ export function CreateAccountModal({
|
||||
title={title}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
import QIF/OFX/QFX files into a local account
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 10 }}>
|
||||
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
|
||||
{upgradingAccountId == null && (
|
||||
<View style={{ gap: 10 }}>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
onPress={() => {
|
||||
state.close();
|
||||
void navigate('/bank-sync');
|
||||
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}
|
||||
>
|
||||
{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'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 ')}
|
||||
.
|
||||
</Warning>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
isDisabled
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
@@ -124,17 +604,22 @@ export function CreateAccountModal({
|
||||
>
|
||||
<Trans>Set up bank sync</Trans>
|
||||
</Button>
|
||||
<Paragraph
|
||||
style={{ fontSize: 15, color: theme.pageTextSubdued }}
|
||||
>
|
||||
<Paragraph style={{ fontSize: 15 }}>
|
||||
<Trans>
|
||||
Configure providers and link accounts from the Bank Sync
|
||||
page.
|
||||
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>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -74,26 +74,22 @@ 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>(() => {
|
||||
@@ -108,25 +104,22 @@ 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, upgradingAccountId]);
|
||||
}, [externalAccounts, syncSource, requisitionId]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
@@ -147,27 +140,11 @@ export function SelectLinkedAccountsModal({
|
||||
});
|
||||
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
|
||||
() => {
|
||||
const initiallyChosenAccounts = Object.fromEntries(
|
||||
return 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<
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateSpendingReportTimeRange } from './reportRanges';
|
||||
|
||||
// In test mode, monthUtils.currentMonth() returns '2017-01'
|
||||
describe('calculateSpendingReportTimeRange', () => {
|
||||
it('preserves the saved compare month for live average reports', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
compare: '2016-12',
|
||||
isLive: true,
|
||||
mode: 'average',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2016-12');
|
||||
expect(compareTo).toBe('2016-12');
|
||||
});
|
||||
|
||||
it('preserves the saved compare month for live budget reports', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
compare: '2016-12',
|
||||
isLive: true,
|
||||
mode: 'budget',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2016-12');
|
||||
expect(compareTo).toBe('2016-12');
|
||||
});
|
||||
|
||||
it('preserves the saved compare months for live single month reports', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
compare: '2016-12',
|
||||
compareTo: '2016-11',
|
||||
isLive: true,
|
||||
mode: 'single-month',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2016-12');
|
||||
expect(compareTo).toBe('2016-11');
|
||||
});
|
||||
|
||||
it('defaults live average reports to the current month without a saved compare month', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
isLive: true,
|
||||
mode: 'average',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2017-01');
|
||||
expect(compareTo).toBe('2017-01');
|
||||
});
|
||||
});
|
||||
@@ -249,12 +249,7 @@ export function calculateSpendingReportTimeRange({
|
||||
mode?: 'budget' | 'average' | 'single-month';
|
||||
}): [string, string] {
|
||||
if (['budget', 'average'].includes(mode) && isLive) {
|
||||
const month = compare ?? monthUtils.currentMonth();
|
||||
return [month, month];
|
||||
}
|
||||
|
||||
if (mode === 'single-month' && isLive && compare) {
|
||||
return [compare, compareTo ?? monthUtils.subMonths(compare, 1)];
|
||||
return [monthUtils.currentMonth(), monthUtils.currentMonth()];
|
||||
}
|
||||
|
||||
const [start, end] = calculateTimeRange(
|
||||
|
||||
@@ -166,10 +166,7 @@ export function ExperimentalFeatures() {
|
||||
</FeatureToggle>
|
||||
{showGoalTemplatesUI && (
|
||||
<View style={{ paddingLeft: 22 }}>
|
||||
<FeatureToggle
|
||||
flag="goalTemplatesUIEnabled"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/7692"
|
||||
>
|
||||
<FeatureToggle flag="goalTemplatesUIEnabled">
|
||||
<Trans>Subfeature: Budget automations UI</Trans>
|
||||
</FeatureToggle>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import type {
|
||||
AccountEntity,
|
||||
GoCardlessToken,
|
||||
} from '@actual-app/core/types/models';
|
||||
import type { GoCardlessToken } from '@actual-app/core/types/models';
|
||||
|
||||
import { pushModal } from './modals/modalsSlice';
|
||||
import type { AppDispatch } from './redux/store';
|
||||
@@ -44,10 +41,7 @@ function _authorize(
|
||||
);
|
||||
}
|
||||
|
||||
export async function authorizeBank(
|
||||
dispatch: AppDispatch,
|
||||
upgradingAccountId?: AccountEntity['id'],
|
||||
) {
|
||||
export async function authorizeBank(dispatch: AppDispatch) {
|
||||
_authorize(dispatch, {
|
||||
onSuccess: async data => {
|
||||
dispatch(
|
||||
@@ -58,7 +52,6 @@ export async function authorizeBank(
|
||||
externalAccounts: data.accounts,
|
||||
requisitionId: data.id,
|
||||
syncSource: 'goCardless',
|
||||
upgradingAccountId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -376,9 +376,7 @@ export default defineConfig(async ({ mode, command }) => {
|
||||
// swSrc: `service-worker/plugin-sw.js`,
|
||||
// },
|
||||
devOptions: {
|
||||
// Disabled: caches stale assets across reloads in dev. Plugin
|
||||
// code that explicitly needs a SW can register one itself.
|
||||
enabled: false,
|
||||
enabled: true, // We need service worker in dev mode to work with plugins
|
||||
type: 'module',
|
||||
},
|
||||
workbox: {
|
||||
|
||||
@@ -39,7 +39,7 @@ const isPlaywrightTest = process.env.EXECUTION_CONTEXT === 'playwright';
|
||||
const isDev = !isPlaywrightTest && !app.isPackaged; // dev mode if not packaged and not playwright
|
||||
|
||||
process.env.lootCoreScript = isDev
|
||||
? '@actual-app/core/lib-dist/electron/bundle.desktop.js' // serve from local output in development (provides hot-reloading)
|
||||
? 'loot-core/lib-dist/electron/bundle.desktop.js' // serve from local output in development (provides hot-reloading)
|
||||
: path.resolve(BUILD_ROOT, 'loot-core/lib-dist/electron/bundle.desktop.js'); // serve from build in production
|
||||
|
||||
// This allows relative URLs to be resolved to app:// which makes
|
||||
@@ -239,11 +239,12 @@ async function startSyncServer() {
|
||||
),
|
||||
};
|
||||
|
||||
// require.resolve will recursively search up the workspace for the module
|
||||
const syncServerRoot = path.dirname(
|
||||
require.resolve('@actual-app/sync-server/package.json'),
|
||||
const serverPath = path.join(
|
||||
// require.resolve will recursively search up the workspace for the module
|
||||
path.dirname(require.resolve('@actual-app/sync-server/package.json')),
|
||||
'build',
|
||||
'app.js',
|
||||
);
|
||||
const serverPath = path.join(syncServerRoot, 'build/app.js');
|
||||
|
||||
const webRoot = path.join(
|
||||
// require.resolve will recursively search up the workspace for the module
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# How to Cut a Release
|
||||
|
||||
## General information
|
||||
|
||||
In the open-source version of Actual, there are 4 NPM packages:
|
||||
In the open-source version of Actual, there are 3 NPM packages:
|
||||
|
||||
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api): The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with Node.
|
||||
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web): A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker.
|
||||
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes the Server CLI, meant to be used with Node.
|
||||
- [@actual-app/cli](https://www.npmjs.com/package/@actual-app/cli): A companion CLI used as a terminal-based client for Actual.
|
||||
|
||||
All packages and the main Actual release are versioned together. That makes it clear which version of the package should be used with the version of Actual.
|
||||
|
||||
@@ -24,7 +21,7 @@ For example:
|
||||
- `v23.3.2` - another bugfix launched later in the month of March;
|
||||
- `v23.4.0` - first release launched on 9th of April, 2023;
|
||||
|
||||
### Release branch
|
||||
## Release branch
|
||||
|
||||
A release branch and PR are automatically cut at 17:00 UTC on the 25th of each month. To cut one manually, run [this GitHub Action](https://github.com/actualbudget/actual/actions/workflows/cut-release-branch.yml).
|
||||
|
||||
@@ -32,15 +29,13 @@ The release notes workflow automatically generates a blog post and updates `docs
|
||||
|
||||
Fixes that need to be included in the release should be cherry-picked onto the release branch manually.
|
||||
|
||||
## Release process
|
||||
|
||||
### Stabilize the release
|
||||
## Stabilise the release
|
||||
|
||||
- [ ] Fix spelling in the generated release notes as needed.
|
||||
- [ ] Share the release PR in the release channel on Discord.
|
||||
- [ ] Wait until at least 2 other maintainers have approved the release.
|
||||
|
||||
### Merge and tag the release
|
||||
## Merge and tag the release
|
||||
|
||||
- [ ] Merge the release PR to master.
|
||||
- [ ] Create the tag on the **release branch** and push it. When the tag is pushed, it triggers the Docker stable image, all NPM packages and the Desktop app to be built and published.
|
||||
@@ -50,21 +45,22 @@ Fixes that need to be included in the release should be cherry-picked onto the r
|
||||
git push {remote} vX.Y.Z
|
||||
```
|
||||
|
||||
All NPM packages should be automatically released and pushed to the NPM registry; confirm [on NPM](https://www.npmjs.com/package/@actual-app/sync-server).
|
||||
All NPM packages should be automatically released and pushed to the NPM registry. Check them here:
|
||||
|
||||
Docker images should be automatically released and pushed to Docker Hub; confirm [on the Docker tags page](https://hub.docker.com/r/actualbudget/actual-server/tags).
|
||||
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api)
|
||||
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web)
|
||||
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server)
|
||||
|
||||
For the Windows Store desktop app, a submission will be automatically uploaded and submitted for certification. The certification process can take up to 3 business days; once complete the app will be in the Store. You can check the update status [on the partner dashboard](https://partner.microsoft.com/en-us/dashboard) if you have permission. Note that the Store UI will not correctly reflect the submission status for about 30 minutes after submission.
|
||||
For the Windows Store desktop app, a submission will be automatically uploaded and submitted for certification. The certification process can take up to 3 business days; once complete the app will be in the Store. You can check the update status [here](https://partner.microsoft.com/en-us/dashboard) if you have permission. Note that the Store UI will not correctly reflect the submission status for about 30 minutes after submission.
|
||||
|
||||
Finally, a draft GitHub release should be automatically created; confirm [on the releases page](https://github.com/actualbudget/actual/releases).
|
||||
Finally, a draft GitHub release should be automatically created [here](https://github.com/actualbudget/actual/releases).
|
||||
|
||||
### Verify the release
|
||||
Once the GitHub release is published, the Flathub publish workflow will trigger for the Linux desktop app. A PR will be created against the [Actual Flathub Repository](https://github.com/flathub/com.actualbudget.actual/pulls) and the core maintainers will be assigned as reviewers. The Core team will review the PR and merge it to `master`, which will kick off a production release to the Flathub Store. It can take anywhere from hours to a few days before the app will be available in the Flathub Store.
|
||||
|
||||
- [ ] Deploy the new server Docker image and do a quick smoke test to verify things still work as expected.
|
||||
- [ ] Perform the same smoke test on the desktop app corresponding to your platform (attached to the draft release).
|
||||
## Finalize the release
|
||||
|
||||
### Finalize the release
|
||||
|
||||
- [ ] Un-draft the GitHub release which will send announcement notifications to all apps and create a PR to the [Actual Flathub Repository](https://github.com/flathub/com.actualbudget.actual/pulls).
|
||||
- [ ] Send an announcement on Discord and Twitter.
|
||||
- [ ] Approve and merge the [Flathub Release PR](https://github.com/flathub/com.actualbudget.actual/pulls) to master. After merge, it can take anywhere from hours to a few days before the app will be available in the Flathub Store.
|
||||
- [ ] After the Docker image for the release is ready and pushed to Docker Hub, remember to deploy it and do a quick smoke test to verify things still work as expected.
|
||||
- [ ] Un-draft the GitHub release which will send announcement notifications to all apps.
|
||||
- [ ] Approve and merge the [Flathub Release PR](https://github.com/flathub/com.actualbudget.actual/pulls) to master.
|
||||
- [ ] Wrap up by sending an announcement on Discord and Twitter.
|
||||
- [ ] Wait one to two days to see if any new bugs show up that need a patch release.
|
||||
|
||||
@@ -55,16 +55,6 @@ export function getStatusLabel(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a query to check if each schedule already has a matching transaction.
|
||||
*
|
||||
* The date lower-bound varies:
|
||||
* - `dateCond.op === 'is'` (one-time): exact `next_date`, no lookback.
|
||||
* - `posts_transaction` (auto-posted recurring): exact `next_date`, since
|
||||
* auto-posted dates are always precise. A lookback here would cause
|
||||
* yesterday's transaction to falsely match today's occurrence.
|
||||
* - Otherwise (manual recurring): 2-day lookback to catch early payments.
|
||||
*/
|
||||
export function getHasTransactionsQuery(schedules) {
|
||||
const filters = schedules.map(schedule => {
|
||||
const dateCond = schedule._conditions?.find(c => c.field === 'date');
|
||||
@@ -75,9 +65,7 @@ export function getHasTransactionsQuery(schedules) {
|
||||
$gte:
|
||||
dateCond && dateCond.op === 'is'
|
||||
? schedule.next_date
|
||||
: schedule.posts_transaction
|
||||
? schedule.next_date
|
||||
: monthUtils.subDays(schedule.next_date, 2),
|
||||
: monthUtils.subDays(schedule.next_date, 2),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
const args = process.argv;
|
||||
@@ -53,7 +54,11 @@ if (values.help) {
|
||||
}
|
||||
|
||||
if (values.version) {
|
||||
console.log('v' + __APP_VERSION__);
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = resolve(__dirname, '../../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
|
||||
console.log('v' + packageJson.version);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
|
||||
146
packages/sync-server/bin/add-import-extensions.mjs
Normal file
146
packages/sync-server/bin/add-import-extensions.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, extname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const buildDir = resolve(__dirname, '../build');
|
||||
const packageRoot = resolve(__dirname, '..');
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(packageRoot, 'package.json'), 'utf-8'),
|
||||
);
|
||||
// publishConfig.imports already has ./build/src/ paths with .js extensions
|
||||
const importsMap = packageJson.publishConfig?.imports || {};
|
||||
|
||||
// Sort wildcard patterns longest-prefix-first so more specific patterns
|
||||
// (e.g. #app-gocardless/services/tests/*) match before broader ones (#app-gocardless/*)
|
||||
const wildcardEntries = Object.entries(importsMap)
|
||||
.filter(([p]) => p.includes('*'))
|
||||
.sort(([a], [b]) => b.length - a.length);
|
||||
|
||||
async function getAllJsFiles(dir) {
|
||||
const files = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await getAllJsFiles(fullPath)));
|
||||
} else if (entry.isFile() && extname(entry.name) === '.js') {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function resolveImportPath(importPath, fromFile) {
|
||||
const baseDir = dirname(fromFile);
|
||||
const resolvedPath = resolve(baseDir, importPath);
|
||||
|
||||
// Check if it's a file with .js extension
|
||||
if (existsSync(`${resolvedPath}.js`)) {
|
||||
return `${importPath}.js`;
|
||||
}
|
||||
|
||||
// Check if it's a directory with index.js
|
||||
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
|
||||
return `${importPath}/index.js`;
|
||||
}
|
||||
|
||||
// Verify the file exists before adding extension
|
||||
if (!existsSync(`${resolvedPath}.js`)) {
|
||||
console.warn(
|
||||
`Warning: Could not resolve import '${importPath}' from ${relative(buildDir, fromFile)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Default: assume it's a file and add .js
|
||||
return `${importPath}.js`;
|
||||
}
|
||||
|
||||
function toRelativePath(target, fromFile) {
|
||||
const absoluteTarget = resolve(packageRoot, target);
|
||||
let rel = relative(dirname(fromFile), absoluteTarget);
|
||||
if (!rel.startsWith('.')) rel = './' + rel;
|
||||
return rel.split('\\').join('/');
|
||||
}
|
||||
|
||||
function resolveSubpathImport(importPath, fromFile) {
|
||||
if (importsMap[importPath]) {
|
||||
return toRelativePath(importsMap[importPath], fromFile);
|
||||
}
|
||||
|
||||
for (const [pattern, target] of wildcardEntries) {
|
||||
const prefix = pattern.replaceAll('*', '');
|
||||
if (importPath.startsWith(prefix)) {
|
||||
const wildcard = importPath.slice(prefix.length);
|
||||
return toRelativePath(target.replaceAll('*', wildcard), fromFile);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Warning: Could not resolve subpath import '${importPath}' from ${relative(buildDir, fromFile)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function addExtensionsToImports(content, filePath) {
|
||||
const importRegex =
|
||||
/(?:import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*\s+from\s+)?|import\s*\(|require\s*\()['"]((\.\.?\/[^'"]+)|(#[^'"]+))['"]/g;
|
||||
|
||||
return content.replace(importRegex, (match, importPath) => {
|
||||
if (!importPath || typeof importPath !== 'string') {
|
||||
return match;
|
||||
}
|
||||
|
||||
if (importPath.startsWith('#')) {
|
||||
const resolved = resolveSubpathImport(importPath, filePath);
|
||||
if (resolved) {
|
||||
return match.replace(importPath, resolved);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
// Skip if already has an extension
|
||||
if (/\.(js|mjs|ts|mts|json)$/.test(importPath)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Skip if ends with / (directory import that already has trailing slash)
|
||||
if (importPath.endsWith('/')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const newImportPath = resolveImportPath(importPath, filePath);
|
||||
return match.replace(importPath, newImportPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function processFile(filePath) {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const newContent = addExtensionsToImports(content, filePath);
|
||||
|
||||
if (content !== newContent) {
|
||||
await writeFile(filePath, newContent, 'utf-8');
|
||||
const relativePath = relative(buildDir, filePath);
|
||||
console.log(`Updated imports in ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const files = await getAllJsFiles(buildDir);
|
||||
await Promise.all(files.map(processFile));
|
||||
console.log(`Processed ${files.length} files`);
|
||||
} catch (error) {
|
||||
console.error('Error processing files:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
26
packages/sync-server/loader.mjs
Normal file
26
packages/sync-server/loader.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, extname, resolve as nodeResolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const extensions = ['.ts', '.js', '.mts', '.mjs'];
|
||||
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
// Only handle relative imports without extensions
|
||||
if (specifier.startsWith('.') && !extname(specifier)) {
|
||||
const parentURL = context.parentURL;
|
||||
if (parentURL) {
|
||||
const parentPath = new URL(parentURL).pathname;
|
||||
const parentDir = dirname(parentPath);
|
||||
|
||||
// Try extensions in order
|
||||
for (const ext of extensions) {
|
||||
const resolvedPath = nodeResolve(parentDir, `${specifier}${ext}`);
|
||||
if (existsSync(resolvedPath)) {
|
||||
return nextResolve(pathToFileURL(resolvedPath).href, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
@@ -41,19 +41,47 @@
|
||||
"#util/title": "./src/util/title/index.js",
|
||||
"#util/*": "./src/util/*.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"imports": {
|
||||
"#db": "./build/src/db.js",
|
||||
"#account-db": "./build/src/account-db.js",
|
||||
"#load-config": "./build/src/load-config.js",
|
||||
"#migrations": "./build/src/migrations.js",
|
||||
"#accounts/*": "./build/src/accounts/*.js",
|
||||
"#app-gocardless/banks/bank.interface": "./build/src/app-gocardless/banks/bank.interface.js",
|
||||
"#app-gocardless/banks/*": "./build/src/app-gocardless/banks/*.js",
|
||||
"#app-gocardless/errors": "./build/src/app-gocardless/errors.js",
|
||||
"#app-gocardless/gocardless-node.types": "./build/src/app-gocardless/gocardless-node.types.js",
|
||||
"#app-gocardless/gocardless.types": "./build/src/app-gocardless/gocardless.types.js",
|
||||
"#app-gocardless/services/*": "./build/src/app-gocardless/services/*.js",
|
||||
"#app-gocardless/services/tests/*": "./build/src/app-gocardless/services/tests/*.js",
|
||||
"#app-gocardless/util/*": "./build/src/app-gocardless/util/*.js",
|
||||
"#app-gocardless/*": "./build/src/app-gocardless/*.js",
|
||||
"#app-pluggyai/*": "./build/src/app-pluggyai/*.js",
|
||||
"#app-simplefin/*": "./build/src/app-simplefin/*.js",
|
||||
"#app-sync/services/*": "./build/src/app-sync/services/*.js",
|
||||
"#app-sync/*": "./build/src/app-sync/*.js",
|
||||
"#scripts/*": "./build/src/scripts/*.js",
|
||||
"#services/*": "./build/src/services/*.js",
|
||||
"#util/title": "./build/src/util/title/index.js",
|
||||
"#util/*": "./build/src/util/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn build && node build/app.js",
|
||||
"start-monitor": "nodemon --exec 'yarn build && node build/app.js' --ignore './build/**/*' --ext 'ts,js' build/app.js",
|
||||
"build": "vite build",
|
||||
"start": "yarn build && node build/app",
|
||||
"start-monitor": "nodemon --exec 'yarn build && node build/app' --ignore './build/**/*' --ext 'ts,js' build/app",
|
||||
"build": "tsgo -b && yarn add-import-extensions && yarn copy-static-assets",
|
||||
"typecheck": "tsgo -b && tsc-strict",
|
||||
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' vitest --run",
|
||||
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js up",
|
||||
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js down",
|
||||
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js up",
|
||||
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js down",
|
||||
"reset-password": "yarn build && node build/scripts/reset-password.js",
|
||||
"disable-openid": "yarn build && node build/scripts/disable-openid.js",
|
||||
"health-check": "yarn build && node build/scripts/health-check.js"
|
||||
"add-import-extensions": "node bin/add-import-extensions.mjs",
|
||||
"copy-static-assets": "rm -rf build/src/sql && cp -r src/sql build/src/sql",
|
||||
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --import ./register-loader.mjs --trace-warnings' vitest --run",
|
||||
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js up",
|
||||
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js down",
|
||||
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js up",
|
||||
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js down",
|
||||
"reset-password": "yarn build && node build/src/scripts/reset-password.js",
|
||||
"disable-openid": "yarn build && node build/src/scripts/disable-openid.js",
|
||||
"health-check": "yarn build && node build/src/scripts/health-check.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
@@ -87,7 +115,6 @@
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/sync-server/register-loader.mjs
Normal file
9
packages/sync-server/register-loader.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import { register } from 'node:module';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const loaderPath = resolve(__dirname, 'loader.mjs');
|
||||
|
||||
register(pathToFileURL(loaderPath).href, pathToFileURL(__dirname));
|
||||
@@ -28,7 +28,6 @@ const authRateLimiter = rateLimit({
|
||||
max: 5, // 5 attempts per window
|
||||
legacyHeaders: false,
|
||||
standardHeaders: true,
|
||||
skipSuccessfulRequests: true,
|
||||
message: { status: 'error', reason: 'too-many-requests' },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import IntegrationBank from './banks/integration-bank';
|
||||
|
||||
// Filename convention: <name>_<bic>.{ts,js} (skips bank.interface,
|
||||
// integration-bank, and any other helper without an underscore).
|
||||
const bankLoaders = import.meta.glob('./banks/*_*.{ts,js}');
|
||||
const dirname = path.resolve(fileURLToPath(import.meta.url), '..');
|
||||
const banksDir = path.resolve(dirname, 'banks');
|
||||
|
||||
async function loadBanks() {
|
||||
const bankHandlers = fs
|
||||
.readdirSync(banksDir)
|
||||
.filter(filename => filename.includes('_') && filename.endsWith('.js'));
|
||||
|
||||
const imports = await Promise.all(
|
||||
Object.values(bankLoaders).map(loader =>
|
||||
loader().then(handler => handler.default),
|
||||
),
|
||||
bankHandlers.map(file => {
|
||||
const fileUrlToBank = pathToFileURL(path.resolve(banksDir, file)); // pathToFileURL for ESM compatibility
|
||||
return import(fileUrlToBank.toString()).then(handler => handler.default);
|
||||
}),
|
||||
);
|
||||
|
||||
return imports;
|
||||
|
||||
@@ -124,32 +124,17 @@ app.get('/metrics', (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// The web frontend.
|
||||
// Dev mode proxies to Vite, which injects inline preamble scripts and uses
|
||||
// a websocket for HMR. Loosen script-src and connect-src accordingly.
|
||||
// `'unsafe-eval'` is required at runtime for the Electron app, so it is
|
||||
// kept in both branches.
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const scriptSrc = isDev
|
||||
? "'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "'self' 'unsafe-eval' blob:";
|
||||
const connectSrc = isDev ? "'self' ws: wss: http: https:" : 'http: https:';
|
||||
const csp = [
|
||||
"default-src 'self' blob:",
|
||||
"img-src 'self' blob: data:",
|
||||
`script-src ${scriptSrc}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
`connect-src ${connectSrc}`,
|
||||
].join('; ');
|
||||
|
||||
// The web frontend
|
||||
app.use((req, res, next) => {
|
||||
res.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
res.set('Content-Security-Policy', csp);
|
||||
res.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
|
||||
);
|
||||
next();
|
||||
});
|
||||
if (isDev) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
'Running in development mode - Proxying frontend routes to React Dev Server',
|
||||
);
|
||||
|
||||
@@ -21,6 +21,8 @@ const defaultDataDir = process.env.ACTUAL_DATA_DIR
|
||||
|
||||
debug(`Project root: '${projectRoot}'`);
|
||||
|
||||
export const sqlDir = path.join(__dirname, 'sql');
|
||||
|
||||
const actualAppWebBuildPath = path.join(
|
||||
path.dirname(require.resolve('@actual-app/web/package.json')),
|
||||
'build',
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
import path from 'node:path';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { load } from 'migrate';
|
||||
|
||||
import { config } from './load-config';
|
||||
|
||||
type MigrationCallback = (err?: Error) => void;
|
||||
type MigrationModule = {
|
||||
up: (next?: MigrationCallback) => void;
|
||||
down: (next?: MigrationCallback) => void;
|
||||
};
|
||||
|
||||
// Vite resolves this glob at build time and inlines a static map of
|
||||
// () => import('chunks/...js') calls. Each migration becomes its own chunk.
|
||||
// Runtime fs reads against a migrations/ directory disappear.
|
||||
const migrationsLoaders = import.meta.glob<MigrationModule>(
|
||||
'../migrations/*.{ts,js}',
|
||||
);
|
||||
|
||||
export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
|
||||
console.log(
|
||||
`Checking if there are any migrations to run for direction "${direction}"...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const sortedKeys = Object.keys(migrationsLoaders).sort();
|
||||
const migrationsModules: Record<string, MigrationModule> = {};
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url)); // this directory
|
||||
const migrationsDir = path.join(__dirname, '../migrations');
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const fileName = key.split('/').pop()!;
|
||||
migrationsModules[fileName] = await migrationsLoaders[key]();
|
||||
try {
|
||||
// Load all script files in the migrations directory
|
||||
const files = await readdir(migrationsDir);
|
||||
const migrationsModules: Record<
|
||||
string,
|
||||
{
|
||||
up: (next?: MigrationCallback) => void;
|
||||
down: (next?: MigrationCallback) => void;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const f of files
|
||||
.filter(
|
||||
f => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts'),
|
||||
)
|
||||
.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0))) {
|
||||
migrationsModules[f] = await import(
|
||||
pathToFileURL(path.join(migrationsDir, f)).href
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt';
|
||||
|
||||
import { openDatabase } from './db';
|
||||
import messagesSql from './sql/messages.sql?raw';
|
||||
import { sqlDir } from './load-config';
|
||||
import { getPathForGroupFile } from './util/paths';
|
||||
|
||||
function getGroupDb(groupId) {
|
||||
@@ -13,7 +14,8 @@ function getGroupDb(groupId) {
|
||||
const db = openDatabase(path);
|
||||
|
||||
if (needsInit) {
|
||||
db.exec(messagesSql);
|
||||
const sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8');
|
||||
db.exec(sql);
|
||||
}
|
||||
|
||||
return db;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
const shebangPlugin = (entryFile: string): Plugin => ({
|
||||
name: 'sync-server-shebang',
|
||||
generateBundle(_options, bundle) {
|
||||
const chunk = bundle[entryFile];
|
||||
if (chunk?.type === 'chunk' && !chunk.code.startsWith('#!')) {
|
||||
chunk.code = `#!/usr/bin/env node\n${chunk.code}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
ssr: {
|
||||
target: 'node',
|
||||
// Inline workspace deps that ship as TS source. Anything else
|
||||
// (express, better-sqlite3, bcrypt, @actual-app/web, etc.) stays
|
||||
// external so Node resolves it at runtime.
|
||||
noExternal: ['@actual-app/crdt'],
|
||||
},
|
||||
build: {
|
||||
ssr: true,
|
||||
target: 'node22',
|
||||
outDir: path.resolve(__dirname, 'build'),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: path.resolve(__dirname, 'app.ts'),
|
||||
'bin/actual-server': path.resolve(__dirname, 'bin/actual-server.js'),
|
||||
'scripts/run-migrations': path.resolve(
|
||||
__dirname,
|
||||
'src/scripts/run-migrations.js',
|
||||
),
|
||||
'scripts/reset-password': path.resolve(
|
||||
__dirname,
|
||||
'src/scripts/reset-password.js',
|
||||
),
|
||||
'scripts/disable-openid': path.resolve(
|
||||
__dirname,
|
||||
'src/scripts/disable-openid.js',
|
||||
),
|
||||
'scripts/enable-openid': path.resolve(
|
||||
__dirname,
|
||||
'src/scripts/enable-openid.js',
|
||||
),
|
||||
'scripts/health-check': path.resolve(
|
||||
__dirname,
|
||||
'src/scripts/health-check.js',
|
||||
),
|
||||
},
|
||||
output: {
|
||||
format: 'esm',
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
assetsInclude: ['**/*.sql'],
|
||||
plugins: [shebangPlugin('bin/actual-server.js')],
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [anoff]
|
||||
---
|
||||
|
||||
Ensure automatic daily schedules are performed each day; deactivate 2-day lookback for payments
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Redesign bank sync and add account flows around the new Bank Sync page.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [Aurora-Flipped]
|
||||
---
|
||||
|
||||
Fixed Spending reports not preserving saved date range selections.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Fix refresh (sync) icon centering in Titlebar.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [emiltb]
|
||||
---
|
||||
|
||||
Fix Cover Overspending dropdown closing when window is too narrow
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Link budget automation UI experimental feature to a feedback issue
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Fix percentage calculation in automation UI error message
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Refactor module resolution to load `@actual-app/crdt` from source during development.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [danielhopkins]
|
||||
---
|
||||
|
||||
Count only failed login attempts against the authentication rate limit
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Fix the desktop app dev mode not starting successfully
|
||||
Reference in New Issue
Block a user