Compare commits

..

18 Commits

Author SHA1 Message Date
Julian Dominguez-Schatz
6fe7e48cf9 Revert "Generate release notes for v26.5.0"
This reverts commit b42c48bed5.
2026-05-03 00:14:11 -04:00
github-actions[bot]
b42c48bed5 Generate release notes for v26.5.0 2026-05-03 04:12:49 +00:00
Julian Dominguez-Schatz
15b6b77a9f Merge branch 'master' into jfdoming/wip-squash 2026-05-03 00:09:20 -04:00
Julian
4c62e2a75d Empty commit to bump CI 2026-05-03 03:39:27 +00:00
Julian Dominguez-Schatz
922f0b8a53 Update docs release date 2026-05-02 13:47:55 -04:00
Julian Dominguez-Schatz
cfd527b446 Fix shared worker resumption after tab suspend (#7656)
* [AI] Fix SharedWorker tab resume recovery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* [AI] Fix SharedWorker reload readiness

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add release notes

* Update packages/desktop-client/src/shared-browser-server-core.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-05-02 13:46:31 -04:00
Julian Dominguez-Schatz
56ea6cba68 Update author
Updated author information in the release notes.
2026-04-27 23:38:40 -04:00
github-actions[bot]
60c0796a92 Generate release notes for v26.5.0 2026-04-27 21:52:09 +00:00
Matt Fiddaman
7b0460d7e9 fix infinite loop when remainder is impossible to solve (#7623)
* fix infinite loop when remainder is impossible to solve

* note
2026-04-27 22:51:20 +01:00
Matt Fiddaman
46ba63f370 increase test coverage for budget templates (#7620)
* [AI] cover existing template engine logic with regression tests

Adds tests for goal template behavior that predates this PR so the
suite can be cherry-picked onto master to confirm no regressions. No
production code changes.

Covers:
- init() validation: schedule names, by/schedule priority match, past
  by-target with and without annual/repeat, percentage source not
  found, special source aliases, duplicate limit/spend/goal
  directives, weekly limit missing start date, invalid limit period,
  unrecognized periodic period
- runRemainder cap clamping and hideDecimal fraction removal
- Income-category branch in runTemplatesForPriority
- getLimitExcess against an aggregate weekly cap
- Past by-target rolling forward via the annual period
- runSchedule full=true (no sinking accumulation), percent and fixed
  adjustments, completed-schedule filtering, past-date error for
  non-repeating schedules, monthly/weekly/daily sinking contribution
  branches when interval exceeds the pay-month-of cap, surplus
  absorption when last-month balance exceeds the target, and
  tracking-budget mode forcing all schedules pay-month-of
- applyMultipleCategoryTemplates orchestration: per-category writes,
  cross-category priority clamping when funds run out, error
  notification path
- applyTemplate force=false skipping already-budgeted categories

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* note

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:51:20 +01:00
github-actions[bot]
4926cd5d76 Generate release notes for v26.5.0 2026-04-27 21:48:23 +00:00
Emil Tveden Bjerglund
05efe9ceee Fix Sankey income bug, when payee it not set (#7632)
* Ensure income categories are shown correct, even if payee is not set

* Add release note
2026-04-27 22:47:35 +01:00
Matt Fiddaman
da522f3e3e add release note highlights 2026-04-27 22:35:34 +01:00
github-actions[bot]
8636591ddf Generate release notes for v26.5.0 2026-04-27 21:30:22 +00:00
Matt Fiddaman
b5d59c7428 fix lint (#7643) 2026-04-27 22:29:28 +01:00
Matt Fiddaman
c02e308739 fix cherrypicked commits not being respected and lint race in release note generation workflow (#7640)
* fix cherrypicked commits not being respected and lint race

* note

* coderabbit suggestions

* fix lint

* make double restore possibility safe
2026-04-27 21:59:57 +01:00
Matt Fiddaman
a5e80edd32 fix release note generation script (#7635)
* fix release note generation script

* note
2026-04-27 20:48:40 +01:00
github-merge-queue
9beeae54e0 🔖 (26.5.0) 2026-04-25 17:13:14 +00:00
56 changed files with 1027 additions and 1453 deletions

View File

@@ -15,8 +15,7 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly",
"__APP_VERSION__": "readonly"
"FS": "readonly"
},
"rules": {
// Import sorting

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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&apos;t have the required permissions to configure bank sync
providers. Please contact an Admin to configure
</Trans>{' '}
{providersNeedingConfiguration
.map(provider => provider.displayName)
.join(' or ')}
.
</Warning>
)}
</View>
);
}

View File

@@ -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');
});
});

View File

@@ -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,
);
}

View File

@@ -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}>

View File

@@ -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,
};
}

View File

@@ -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}
>

View File

@@ -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]);

View File

@@ -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 });
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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]);

View File

@@ -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) {

View File

@@ -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 (

View File

@@ -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&apos;t have the required permissions to set up
secrets. Please contact an Admin to configure
</Trans>{' '}
{[
isGoCardlessSetupComplete ? '' : 'GoCardless',
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
]
.filter(Boolean)
.join(' or ')}
.
</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>
</>
)}

View File

@@ -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<

View File

@@ -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');
});
});

View File

@@ -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(

View File

@@ -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>

View File

@@ -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,
},
},
}),

View File

@@ -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: {

View File

@@ -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

View File

@@ -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.

View File

@@ -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),
},
},
};

View File

@@ -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();
}

View 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();

View 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);
}

View File

@@ -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"
}
}

View 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));

View File

@@ -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' },
});

View File

@@ -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;

View File

@@ -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',
);

View File

@@ -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',

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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')],
});

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [anoff]
---
Ensure automatic daily schedules are performed each day; deactivate 2-day lookback for payments

View File

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

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [Aurora-Flipped]
---
Fixed Spending reports not preserving saved date range selections.

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [Juulz]
---
Fix refresh (sync) icon centering in Titlebar.

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [emiltb]
---
Fix Cover Overspending dropdown closing when window is too narrow

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [matt-fidd]
---
Link budget automation UI experimental feature to a feedback issue

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [matt-fidd]
---
Fix percentage calculation in automation UI error message

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor module resolution to load `@actual-app/crdt` from source during development.

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [danielhopkins]
---
Count only failed login attempts against the authentication rate limit

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MikesGlitch]
---
Fix the desktop app dev mode not starting successfully

View File

@@ -203,7 +203,6 @@ __metadata:
pluggy-sdk: "npm:^0.83.0"
supertest: "npm:^7.2.2"
typescript-strict-plugin: "npm:^2.4.4"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
winston: "npm:^3.19.0"
bin: