Compare commits

...

22 Commits

Author SHA1 Message Date
github-actions[bot]
8393a65d7a [AI] Fix applyAppUpdate hanging in dev mode
In dev mode browser-preload's updateSW was () => undefined, so
applyAppUpdate() — which calls updateSW() and then awaits a
deliberately never-resolving promise (waiting for the SW-driven page
reload) — hung the renderer instead of refreshing. In prod the page
is replaced by the new service worker, so the never-resolving await is
fine. The dev path now triggers a plain window.location.reload() so
the page reloads and the never-settling await is irrelevant, matching
prod's effective behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:52:50 +01:00
github-actions[bot]
6b351eafc7 [AI] Restore bank-factory glob to ./banks/*_*.{ts,js}
Re-apply the glob widening originally added in 145868f9d. It was
reverted in 531b1a191 because the desktop e2e was failing — that
failure is now traced to the rebuild-electron breakage (fixed in
6e8ac0784), not to this glob. Mirroring migrations.ts so future TS
bank handlers are picked up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:46 +01:00
github-actions[bot]
43fba254b5 [AI] Restore CSP unsafe-eval comment
Bring back the explanatory comment that was stripped diagnostically in
99682268c. Now that the desktop e2e regression is traced to
rebuild-electron and not to anything in this branch, we can keep the
documentation noting why 'unsafe-eval' is retained in both CSP branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:29:24 +01:00
github-actions[bot]
6e8ac07846 [AI] Make rebuild-electron actually rebuild better-sqlite3
PR #7712 simplified rebuild-electron to just `electron-rebuild -f -o
better-sqlite3,bcrypt` from the repo root. Two problems with that:

  1. Without `-m`, electron-rebuild scans the root workspace's package.json
     for native deps. better-sqlite3 isn't a direct root dep — it lives
     under packages/sync-server/ — so the scan returns no candidates and
     the rebuild silently no-ops.
  2. Without --build-from-source, electron-rebuild defers to
     prebuild-install, which downloads a stale prebuilt binary keyed off
     better-sqlite3's package.json (ABI 127) instead of recompiling
     against Electron 39's bundled Node ABI 140. The download succeeds
     and "Rebuild Complete" prints, but the resulting `better_sqlite3.node`
     can't `dlopen` inside Electron's utility process — sync-server
     crashes immediately on db init, the renderer's startSyncServer IPC
     never resolves, and the e2e test hangs on "Configure your server".

Point -m at packages/desktop-electron (which transitively pulls in
better-sqlite3 and bcrypt via @actual-app/sync-server) and force a real
compile via --build-from-source. Verified locally: better-sqlite3
rebuilds to darwin-arm64-140 and the desktop e2e onboarding test passes
in 6s instead of hanging for 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:23:46 +01:00
github-actions[bot]
99682268cc [AI] Strip CSP comment to restore identical state to 9513c1e16
The desktop e2e has been failing despite my prior commits being a strict
revert (only difference was a 2-line comment, which can't change runtime).
Removing even the comment so the branch matches 9513c1e16's relevant
files exactly, to isolate whether the failure is from the master merge
or from CI-environment drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:55:53 +01:00
Matiss Janis Aboltins
749aee4f44 Merge branch 'master' into matiss/crdt-source-loading 2026-05-05 21:29:45 +01:00
github-actions[bot]
531b1a1914 [AI] Revert bank-factory glob change
Widening the glob to ./banks/*_*.{ts,js} broke the desktop e2e tests in
CI even though every current handler is .js and the brace expansion
matches no .ts files locally. Reverting to ./banks/*_*.js — the change
had no behavioural benefit since there are no TS handlers, so the
nitpick isn't worth chasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:23:32 +01:00
github-actions[bot]
f08490052f [AI] Restore 'unsafe-eval' in production CSP for Electron
The Electron app needs `'unsafe-eval'` at runtime, so revert the dev-only
restriction and keep `'unsafe-eval'` in both branches. Comment updated to
record the actual reason instead of marking it as removable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:10:48 +01:00
github-actions[bot]
145868f9da [AI] Address review feedback
- sync-server CSP: drop 'unsafe-eval' from the production script-src;
  the bundle has no genuine eval/new Function usage (only a defensive
  branch in setimmediate's polyfill that's never hit). Keep it on the
  dev branch where Vite's HMR runtime relies on it. Add a comment so
  it's obvious which branch needs it and why.
- bank-factory: widen the loader glob to ./banks/*_*.{ts,js} so
  TypeScript handlers are discovered too, mirroring migrations.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:04:34 +01:00
Michael Clark
44fc959ed8 :electron: Fix electron dev mode not starting (#7712)
* fix electron dev mode

* release notes
2026-05-05 08:08:49 +00:00
Dan Hopkins
d787d0ce43 fix: only count failed attempts against auth rate limit (#7707)
* fix: only count failed attempts against auth rate limit

Add skipSuccessfulRequests: true to authRateLimiter so that successful
logins do not consume quota. This fixes breakage for API clients
(actual-cli, actual-mcp, custom scripts) that re-authenticate per
operation — they always provide the correct password, so they should
never be rate-limited.

Brute-force attackers generate repeated failures and still hit the wall.

Fixes #7706

* Update upcoming-release-notes/7706.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* fix: rename release note to match PR number

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-05-04 22:27:18 +00:00
github-actions[bot]
9513c1e160 [AI] Restructure sync-server to build with Vite
Replace the hand-rolled tsgo + add-import-extensions + copy-static-assets
+ runtime loader pipeline with a single Vite SSR build. Bundles every
entry (app, bin/actual-server, scripts/*) and inlines @actual-app/crdt
source so Node never has to resolve TS at runtime — the
MODULE_TYPELESS_PACKAGE_JSON warning that surfaced via crdt's source
exports is gone. Migrations and bank handlers move from readdir-based
dynamic imports to import.meta.glob; messages.sql becomes a ?raw import.

Drop loader.mjs, register-loader.mjs, start.mjs, and
bin/add-import-extensions.mjs. Electron's startSyncServer() forks
build/app.js directly. publishConfig.imports goes away (subpath imports
are resolved at build time and don't appear in the bundle).

In dev (start:server-dev) sync-server proxies to Vite, so loosen the CSP
to allow Vite's inline preamble script and HMR websocket — production
CSP is unchanged. desktop-client skips registerSW() in dev (and disables
vite-plugin-pwa's devOptions) so stale cached assets don't override
edits between page loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:32:22 +01:00
Aurora-Flipped
2c3e2a34fd Fix/spending report date range save (#7672)
* Fix saved spending report date range

* Add release note
2026-05-04 20:28:19 +00:00
lelemm
78d533c800 Bank sync page refactor (#7449)
* Bank sync refactor extracted from plugins

* code review

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7449

* [AI] Resolve bank sync PR conflicts

Co-authored-by: lelemm <lelemm@users.noreply.github.com>

* Change author name in 7449 release notes

Updated author name in release notes.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: lelemm <lelemm@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-05-04 14:51:33 +00:00
Juulz
49f6b21f2c [Bugfix]🐛 Fix refresh icon (sync) centering in Titlebar (#7674)
* Fix svg rendering in Titlebar component

* Fix conditional rendering of syncState text

* Fix string interpolation in Titlebar component

* Add release notes for bugfix on refresh icon centering

* Clarify refresh icon centering fix in Titlebar
2026-05-04 14:49:33 +00:00
Emil Tveden Bjerglund
9f05207fe8 Fix Cover Overspending menu closing when view too narrow (#7687)
* Fix Cover Overspending menu closing when view too narrow

* Update upcoming-release-notes/7687.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Increase min width

* Set minWidth instead

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-04 14:48:30 +00:00
Matt Fiddaman
8366c442a2 automation UI: seperate percentage sources in error logic (#7694)
* seperate percentage sources in error logic

* note

* restructure and add test

* fix income category resolution and add regression test
2026-05-04 13:59:55 +00:00
github-actions[bot]
e661951753 Add release notes for PR #7702 2026-05-04 13:44:21 +01:00
github-actions[bot]
fc5e836a02 [AI] Load @actual-app/crdt from source in dev, only bundle for publish
@actual-app/crdt's local exports now point at src/index.ts so consumers
(sync-server, loot-core, desktop-client) never see a stale Vite bundle.
publishConfig keeps the dist/ mapping for npm consumers. crdt's
tsconfig switches to bundler module resolution to match the rest of
the workspace (no extensions in source imports).

Sync-server's existing extension-resolution loader is extended to also
handle directory-index imports (./crdt → ./crdt/index.ts), and the
standalone `start` / `start-monitor` scripts now invoke Node with
--import ./register-loader.mjs so the loader is in place before crdt's
source resolves.

Electron's utilityProcess.fork accepts execArgv but doesn't actually
preload --import modules, so a new packages/sync-server/start.mjs
bootstrap entry registers the loader imperatively and then dynamic-
imports build/app.js. desktop-electron's startSyncServer() points the
fork at start.mjs. sync-server's "files" array now ships start.mjs,
register-loader.mjs and loader.mjs so packaged Electron / npm
consumers actually receive them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:32:18 +01:00
Matt Fiddaman
4b73fd7e45 link budget automation UI experimental feature to a feedback issue (#7693)
* link to feedback issue

* note
2026-05-03 22:46:10 +00:00
Julian Dominguez-Schatz
c593bda145 Update release docs to reflect latest process (#7690)
* Update release docs to reflect latest process

* Add missed space

* GitHub doesn't like non-American spelling

* PR feedback
2026-05-03 22:02:08 +00:00
Andreas Offenhaeuser
1b86bba2cd [Bugfix] Disable 2-day lookback for automatic transactions (#7299)
* disable 2-day lookback for automatic transactions

* add PR release notes
2026-05-03 21:44:28 +00:00
56 changed files with 1451 additions and 1025 deletions

View File

@@ -15,7 +15,8 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly"
"FS": "readonly",
"__APP_VERSION__": "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/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"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,14 +10,10 @@
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
@@ -25,7 +21,9 @@
"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": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ES2022",
"moduleResolution": "bundler",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

@@ -335,10 +335,17 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
// 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,
});
global.Actual = {
IS_DEV,

View File

@@ -243,8 +243,8 @@ function ServerSyncButton({ style, isMobile = false }: ServerSyncButtonProps) {
) : (
<AnimatedRefresh animating={syncing} />
)}
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
{syncState === 'disabled' ? t('Disabled') : null}
<Text style={isMobile ? { ...mobileTextStyle } : null}>
{syncState === 'disabled' ? ` ${t('Disabled')}` : null}
</Text>
</Button>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -512,7 +512,10 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
placement="bottom end"
isOpen={balanceMenuOpen}
onOpenChange={() => setBalanceMenuOpen(false)}
style={{ margin: 1 }}
style={{
margin: 1,
minWidth: 190,
}}
isNonModal
{...balancePosition}
>

View File

@@ -7,7 +7,7 @@ export function useBudgetAutomationCategories() {
const { t } = useTranslation();
const { data: { grouped } = { grouped: [] } } = useCategories();
const categories = useMemo(() => {
const incomeGroup = grouped.filter(group => group.name === 'Income')[0];
const incomeGroups = grouped.filter(group => group.is_income);
return [
{
id: '',
@@ -21,7 +21,10 @@ export function useBudgetAutomationCategories() {
},
],
},
{ ...incomeGroup, name: t('Income categories') },
...incomeGroups.map(group => ({
...group,
name: t('Income categories'),
})),
];
}, [grouped, t]);

View File

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

View File

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

View File

@@ -19,8 +19,10 @@ import {
} from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import { validateAutomation } from '#components/budget/goals/validateAutomation';
import type { GlobalConflictKind } from '#components/budget/goals/validateAutomation';
import {
validateAutomation,
validatePercentageAllocation,
} from '#components/budget/goals/validateAutomation';
import { Link } from '#components/common/Link';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
@@ -212,12 +214,7 @@ export function BudgetAutomationsBody({
dryRun?.perTemplate?.[i] != null ? dryRun.perTemplate[i] : null,
);
const hasErrors = automationErrors.some(error => error !== null);
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 conflict = validatePercentageAllocation(templates);
const categoryNameMap: Record<string, string> = {};
for (const group of categories) {

View File

@@ -65,9 +65,20 @@ 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 =
parsedTemplates && !hasUnsupportedDirective
? migrateTemplatesToAutomations(parsedTemplates)
resolved && !hasUnsupportedDirective
? migrateTemplatesToAutomations(resolved)
: null;
return (

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
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,7 +249,12 @@ export function calculateSpendingReportTimeRange({
mode?: 'budget' | 'average' | 'single-month';
}): [string, string] {
if (['budget', 'average'].includes(mode) && isLive) {
return [monthUtils.currentMonth(), monthUtils.currentMonth()];
const month = compare ?? monthUtils.currentMonth();
return [month, month];
}
if (mode === 'single-month' && isLive && compare) {
return [compare, compareTo ?? monthUtils.subMonths(compare, 1)];
}
const [start, end] = calculateTimeRange(

View File

@@ -166,7 +166,10 @@ export function ExperimentalFeatures() {
</FeatureToggle>
{showGoalTemplatesUI && (
<View style={{ paddingLeft: 22 }}>
<FeatureToggle flag="goalTemplatesUIEnabled">
<FeatureToggle
flag="goalTemplatesUIEnabled"
feedbackLink="https://github.com/actualbudget/actual/issues/7692"
>
<Trans>Subfeature: Budget automations UI</Trans>
</FeatureToggle>
</View>

View File

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

View File

@@ -376,7 +376,9 @@ export default defineConfig(async ({ mode, command }) => {
// swSrc: `service-worker/plugin-sw.js`,
// },
devOptions: {
enabled: true, // We need service worker in dev mode to work with plugins
// Disabled: caches stale assets across reloads in dev. Plugin
// code that explicitly needs a SW can register one itself.
enabled: false,
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
? 'loot-core/lib-dist/electron/bundle.desktop.js' // serve from local output in development (provides hot-reloading)
? '@actual-app/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,12 +239,11 @@ async function startSyncServer() {
),
};
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',
// 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(syncServerRoot, 'build/app.js');
const webRoot = path.join(
// require.resolve will recursively search up the workspace for the module

View File

@@ -1,10 +1,13 @@
# How to Cut a Release
In the open-source version of Actual, there are 3 NPM packages:
## General information
In the open-source version of Actual, there are 4 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.
@@ -21,7 +24,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).
@@ -29,13 +32,15 @@ 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.
## Stabilise the release
## Release process
### Stabilize 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.
@@ -45,22 +50,21 @@ 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. Check them here:
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).
- [@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)
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).
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.
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.
Finally, a draft GitHub release should be automatically created [here](https://github.com/actualbudget/actual/releases).
Finally, a draft GitHub release should be automatically created; confirm [on the releases page](https://github.com/actualbudget/actual/releases).
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.
### Verify the release
## Finalize the release
- [ ] 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).
- [ ] 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.
### 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.

View File

@@ -55,6 +55,16 @@ 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');
@@ -65,7 +75,9 @@ export function getHasTransactionsQuery(schedules) {
$gte:
dateCond && dateCond.op === 'is'
? schedule.next_date
: monthUtils.subDays(schedule.next_date, 2),
: schedule.posts_transaction
? schedule.next_date
: monthUtils.subDays(schedule.next_date, 2),
},
},
};

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
const args = process.argv;
@@ -54,11 +53,7 @@ if (values.help) {
}
if (values.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);
console.log('v' + __APP_VERSION__);
process.exit();
}

View File

@@ -1,146 +0,0 @@
#!/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

@@ -1,26 +0,0 @@
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,47 +41,19 @@
"#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",
"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",
"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",
"typecheck": "tsgo -b && tsc-strict",
"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"
"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"
},
"dependencies": {
"@actual-app/crdt": "workspace:*",
@@ -115,6 +87,7 @@
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vitest": "^4.1.2"
}
}

View File

@@ -1,9 +0,0 @@
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,6 +28,7 @@ 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,22 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import IntegrationBank from './banks/integration-bank';
const dirname = path.resolve(fileURLToPath(import.meta.url), '..');
const banksDir = path.resolve(dirname, 'banks');
// 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}');
async function loadBanks() {
const bankHandlers = fs
.readdirSync(banksDir)
.filter(filename => filename.includes('_') && filename.endsWith('.js'));
const imports = await Promise.all(
bankHandlers.map(file => {
const fileUrlToBank = pathToFileURL(path.resolve(banksDir, file)); // pathToFileURL for ESM compatibility
return import(fileUrlToBank.toString()).then(handler => handler.default);
}),
Object.values(bankLoaders).map(loader =>
loader().then(handler => handler.default),
),
);
return imports;

View File

@@ -124,17 +124,32 @@ app.get('/metrics', (_req, res) => {
});
});
// The web frontend
// 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('; ');
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',
"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:;",
);
res.set('Content-Security-Policy', csp);
next();
});
if (process.env.NODE_ENV === 'development') {
if (isDev) {
console.log(
'Running in development mode - Proxying frontend routes to React Dev Server',
);

View File

@@ -21,8 +21,6 @@ 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,40 +1,34 @@
import { readdir } from 'node:fs/promises';
import path, { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
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}"...`,
);
const __dirname = dirname(fileURLToPath(import.meta.url)); // this directory
const migrationsDir = path.join(__dirname, '../migrations');
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;
}
> = {};
const sortedKeys = Object.keys(migrationsLoaders).sort();
const migrationsModules: Record<string, MigrationModule> = {};
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
);
for (const key of sortedKeys) {
const fileName = key.split('/').pop()!;
migrationsModules[fileName] = await migrationsLoaders[key]();
}
return new Promise<void>((resolve, reject) => {

View File

@@ -1,10 +1,9 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt';
import { openDatabase } from './db';
import { sqlDir } from './load-config';
import messagesSql from './sql/messages.sql?raw';
import { getPathForGroupFile } from './util/paths';
function getGroupDb(groupId) {
@@ -14,8 +13,7 @@ function getGroupDb(groupId) {
const db = openDatabase(path);
if (needsInit) {
const sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8');
db.exec(sql);
db.exec(messagesSql);
}
return db;

View File

@@ -0,0 +1,73 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -203,6 +203,7 @@ __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: