Compare commits

..

2 Commits

Author SHA1 Message Date
Claude
e388962724 [AI] Build @actual-app/crdt before emitting loot-core declarations
`tsgo -p tsconfig.json` on `loot-core` resolves `@actual-app/crdt` via its
package.json `types` field, which points at `dist/index.d.ts`. On a fully
clean tree that file does not exist, so the declaration emit fails with
TS2307 before reaching loot-core's own declarations.

Match `bin/package-electron`, which builds `@actual-app/crdt` (line 51)
before invoking `tsgo -p tsconfig.json` for loot-core (line 65).
2026-04-29 22:58:46 +00:00
Claude
7359d4e9b3 [AI] Build loot-core declarations as part of yarn start:desktop
`yarn start:desktop` calls `desktop-electron`'s `tsgo` build, which expects
`@actual-app/core`'s declaration output (`packages/loot-core/@types/`) to
already exist because of the project reference in `desktop-electron/tsconfig.json`.
On a fresh checkout — or any time `@types/` has been cleaned — the build
fails with TS6305 ("Output file ... has not been built from source file ...").

`bin/package-electron` (used by CI / `yarn build:desktop`) emits those
declarations explicitly before building electron. Mirror the same step in
`desktop-dependencies` so the local dev flow is self-sufficient.
2026-04-29 22:04:58 +00:00
298 changed files with 2843 additions and 6700 deletions

View File

@@ -1,6 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: true
enabled: false
reviews:
request_changes_workflow: true
review_status: false

View File

@@ -1,4 +1,4 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
## Description

View File

@@ -61,7 +61,6 @@ Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
ecf
EDATE
ENTERCARD
Entra
@@ -141,6 +140,8 @@ pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
Qatari
QNTOFRP
@@ -171,6 +172,7 @@ SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB

View File

@@ -9,7 +9,7 @@ runs:
node-version: 22
- name: Install dependencies
shell: bash
run: yarn workspaces focus actual @actual-app/ci-actions
run: yarn workspaces focus @actual-app/ci-actions
- name: Generate release notes
shell: bash
env:

View File

@@ -46,12 +46,13 @@ jobs:
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: yarn build:browser --skip-translations
run: |
yarn workspace plugins-service build
yarn workspace @actual-app/crdt build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:

3
.gitignore vendored
View File

@@ -42,9 +42,6 @@ bundle.desktop.js.map
bundle.mobile.js
bundle.mobile.js.map
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
.venv/
# Yarn
.pnp.*
.yarn/*

View File

@@ -15,8 +15,7 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly",
"__APP_VERSION__": "readonly"
"FS": "readonly"
},
"rules": {
// Import sorting
@@ -371,8 +370,7 @@
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
"rules": {
"actual/no-untranslated-strings": "off",
"actual/prefer-logger-over-console": "off",
"typescript/unbound-method": "off"
"actual/prefer-logger-over-console": "off"
}
},
{

View File

@@ -4,30 +4,21 @@ ROOT=`dirname $0`
cd "$ROOT/.."
SKIP_TRANSLATIONS=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [ "$SKIP_TRANSLATIONS" = false ]; then
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
lage build:browser --to=@actual-app/web
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"

View File

@@ -57,7 +57,8 @@ yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn build:browser
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build

View File

@@ -25,14 +25,6 @@ module.exports = {
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
// Not cached: the script stages files into public/ and build-stats/ that
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
// effects.
'build:browser': {
type: 'npmScript',
dependsOn: ['^build'],
cache: false,
},
},
cacheOptions: {
cacheStorageConfig: {

View File

@@ -24,16 +24,19 @@
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service build:desktop-types",
"build:desktop-types": "yarn workspace @actual-app/crdt build && yarn workspace @actual-app/core exec tsgo -p tsconfig.json",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
@@ -52,7 +55,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",

View File

@@ -6,11 +6,6 @@ import { vi } from 'vitest';
import * as api from './index';
declare global {
var IS_TESTING: boolean;
var currentMonth: string | null;
}
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.5.0",
"version": "26.4.0",
"description": "An API for Actual",
"license": "MIT",
"repository": {
@@ -10,9 +10,7 @@
},
"files": [
"@types",
"dist",
"!@types/**/*.test.d.ts",
"!@types/**/*.test.d.ts.map"
"dist"
],
"main": "dist/index.js",
"types": "@types/index.d.ts",

View File

@@ -35,6 +35,7 @@
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]

View File

@@ -69,8 +69,6 @@ const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
await exec(`git config user.name '${botName}'`);
await exec(`git config user.email '${botEmail}'`);
const AUTOGEN_MARKER = '<!-- release-notes:auto-generated -->';
await group('Prepare branch', async () => {
if (process.env.GITHUB_HEAD_REF) {
await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, {
@@ -81,34 +79,17 @@ await group('Prepare branch', async () => {
});
}
// recover deleted release note files from previous generation commits
const baseRef = process.env.GITHUB_BASE_REF || 'master';
await exec(`git fetch origin ${baseRef}`, { stdio: 'inherit' });
const { stdout: mergeBase } = await exec(
`git merge-base HEAD origin/${baseRef}`,
// the previous generation commit deletes source files from
// upcoming-release-notes, rebase it out so we can regenerate from all of them
const { stdout: commitHash } = await exec(
`git log --grep='${commitMessage}' --format=%H -1`,
);
const base = mergeBase.trim();
const { stdout: genLog } = await exec(
`git log --grep='${commitMessage}' --format=%H ${base}..HEAD`,
);
const genCommits = genLog.split('\n').filter(Boolean);
console.log(
`Reversing upcoming-release-notes deletions from ${genCommits.length} prior generation commit(s)`,
);
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
for (const sha of genCommits) {
const patchPath = join(tmpDir, `revert-${sha}.patch`);
try {
await exec(
`git diff --diff-filter=D ${sha}~1..${sha} -- upcoming-release-notes > ${patchPath}`,
);
const { size } = await fs.stat(patchPath);
if (size > 0) {
await exec(`git apply -R --3way ${patchPath}`, { stdio: 'inherit' });
}
} finally {
await fs.unlink(patchPath).catch(() => undefined);
}
const hash = commitHash.trim();
if (hash) {
console.log(`Dropping previous release notes commit ${hash}`);
await exec(`git rebase --onto ${hash}~1 ${hash}`, {
stdio: 'inherit',
});
}
});
@@ -126,14 +107,13 @@ if (files.length === 0) {
const highlights = '- TODO: Add release highlights';
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const releasesPath = 'packages/docs/docs/releases.md';
await group('Generate blog post', async () => {
const template = `---
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const blogContent = `---
title: Release ${version}
description: New release of Actual.
date: ${releaseDate}T10:00
@@ -149,60 +129,18 @@ ${highlights}
**Docker Tag: ${version}**
${AUTOGEN_MARKER}
${categorizedNotes}
`;
let blogContent;
try {
const existing = await fs.readFile(blogPath, 'utf-8');
const idx = existing.indexOf(AUTOGEN_MARKER);
if (idx === -1) {
console.log(
`WARNING: ${blogPath} missing ${AUTOGEN_MARKER}, rewriting from template`,
);
blogContent = template;
} else {
blogContent =
existing.slice(0, idx + AUTOGEN_MARKER.length) +
'\n' +
categorizedNotes +
'\n';
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
blogContent = template;
}
await fs.writeFile(blogPath, blogContent);
console.log(`Wrote ${blogPath}`);
});
await group('Update releases.md', async () => {
const releasesPath = 'packages/docs/docs/releases.md';
const existing = await fs.readFile(releasesPath, 'utf-8');
const sectionRe = new RegExp(
`(^|\\n)## ${escapeRegExp(version)}\\n[\\s\\S]*?(?=\\n## |$)`,
);
const match = existing.match(sectionRe);
let updated;
if (match) {
const section = match[0];
const idx = section.indexOf(AUTOGEN_MARKER);
if (idx === -1) {
console.log(
`WARNING: section for ${version} in ${releasesPath} missing ${AUTOGEN_MARKER}, leaving as-is`,
);
updated = existing;
} else {
const newSection =
section.slice(0, idx + AUTOGEN_MARKER.length) + '\n' + categorizedNotes;
updated = existing.replace(section, newSection);
}
} else {
const newSection = `## ${version}
const newSection = `## ${version}
Release date: ${releaseDate}
@@ -210,14 +148,12 @@ ${highlights}
**Docker Tag: ${version}**
${AUTOGEN_MARKER}
${categorizedNotes}`;
updated = existing.replace(
'# Release Notes\n',
`# Release Notes\n\n${newSection}\n`,
);
}
const updated = existing.replace(
'# Release Notes\n',
`# Release Notes\n\n${newSection}\n`,
);
await fs.writeFile(releasesPath, updated);
console.log(`Updated ${releasesPath}`);
@@ -229,28 +165,13 @@ await group('Remove used release notes', async () => {
);
});
await group('Format generated files', async () => {
await exec(`yarn exec oxfmt ${blogPath} ${releasesPath}`, {
stdio: 'inherit',
});
});
await group('Commit and push', async () => {
await exec(
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
{ stdio: 'inherit' },
);
try {
await exec('git diff --cached --quiet');
console.log('No changes to commit');
return;
} catch {
// there are staged changes
}
await exec(`git commit -m '${commitMessage}'`);
await exec('git push origin', { stdio: 'inherit' });
await exec('git push --force-with-lease origin', { stdio: 'inherit' });
});
async function parseReleaseNotes(dir) {
@@ -284,10 +205,6 @@ async function parseReleaseNotes(dir) {
return { notesByCategory, files };
}
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function formatNotes(notes) {
return Object.entries(notes)
.filter(([_, values]) => values.length > 0)

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/cli",
"version": "26.5.0",
"version": "26.4.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"repository": {

View File

@@ -4,16 +4,16 @@
"description": "CRDT layer of Actual",
"license": "MIT",
"files": [
"dist",
"!dist/**/*.test.d.ts",
"!dist/**/*.test.d.ts.map",
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
"dist"
],
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
@@ -21,14 +21,12 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
},
"scripts": {
"build:node": "vite build",
"proto:generate": "./bin/generate-proto",
"build": "yarn run build:node && tsgo -b",
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
"test": "vitest --run",
"typecheck": "tsgo -b"
},

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false
},
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -8,7 +8,6 @@ coverage
test-results
playwright-report
blob-report
.playwright-cli
# production
build

View File

@@ -0,0 +1,17 @@
#!/bin/sh -ex
ROOT=`dirname $0`
cd "$ROOT/.."
echo "Building the browser..."
rm -fr build
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
yarn build --mode=browser
rm -fr build-stats
mkdir build-stats
mv build/kcab/stats.json build-stats/loot-core-stats.json
mv ./stats.json build-stats/web-stats.json

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,5 +1,5 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import type { Browser, Locator, Page } from '@playwright/test';
import type { Browser, Locator } from '@playwright/test';
/**
* Disable CSS transitions and animations globally in e2e (non-VRT) runs.
@@ -51,7 +51,7 @@ export const test = process.env.VRT
});
export const expect = baseExpect.extend({
async toMatchThemeScreenshots(target: Locator | Page) {
async toMatchThemeScreenshots(locator: Locator) {
// Disable screenshot assertions in regular e2e tests;
// only enable them when doing VRT tests
if (!process.env.VRT) {
@@ -62,33 +62,38 @@ export const expect = baseExpect.extend({
}
const config = {
mask: [target.locator('[data-vrt-mask="true"]')],
mask: [locator.locator('[data-vrt-mask="true"]')],
maxDiffPixels: 5,
};
const page: Page = 'page' in target ? target.page() : target;
const dataThemeLocator = page.locator('[data-theme]');
// Get the data-theme attribute from page.
// If there is a page() function, it means that the locator
// is not a page object but a locator object.
const dataThemeLocator =
typeof locator.page === 'function'
? locator.page().locator('[data-theme]')
: locator.locator('[data-theme]');
// Check lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to darkmode and check
await page.evaluate(() => window.Actual.setTheme('dark'));
await locator.evaluate(() => window.Actual.setTheme('dark'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to midnight theme and check
await page.evaluate(() => window.Actual.setTheme('midnight'));
await locator.evaluate(() => window.Actual.setTheme('midnight'));
await baseExpect(dataThemeLocator).toHaveAttribute(
'data-theme',
'midnight',
);
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch back to lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
return {
message: () => 'pass',
pass: true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,9 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"types": ["@playwright/test", "node"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../../loot-core/typings/window.ts"]
}

View File

@@ -86,10 +86,7 @@
'Arial',
sans-serif
);
font-feature-settings:
'ss01',
'ss04',
'calt' 0;
font-feature-settings: 'ss01', 'ss04';
}
html,

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "26.5.0",
"version": "26.4.0",
"license": "MIT",
"repository": {
"type": "git",
@@ -34,19 +34,12 @@
"#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",
"#components/budget/goals/automationExamples": "./src/components/budget/goals/automationExamples.ts",
"#components/budget/goals/constants": "./src/components/budget/goals/constants.ts",
"#components/budget/goals/displayTemplateMeta": "./src/components/budget/goals/displayTemplateMeta.ts",
"#components/budget/goals/formatMonthLabel": "./src/components/budget/goals/formatMonthLabel.ts",
"#components/budget/goals/reducer": "./src/components/budget/goals/reducer.ts",
"#components/budget/goals/useBudgetAutomationCategories": "./src/components/budget/goals/useBudgetAutomationCategories.ts",
"#components/budget/goals/validateAutomation": "./src/components/budget/goals/validateAutomation.ts",
"#components/budget/util": "./src/components/budget/util.ts",
"#components/codemirror/autocompleteTabAccept": "./src/components/codemirror/autocompleteTabAccept.ts",
"#components/mobile/utils": "./src/components/mobile/utils.ts",
@@ -111,7 +104,7 @@
"start:browser": "cross-env ./bin/watch-browser",
"watch": "cross-env BROWSER=none yarn start",
"build": "vite build",
"build:browser": "vite build --mode=browser",
"build:browser": "cross-env ./bin/build-browser",
"generate:i18n": "i18next",
"test": "vitest --run",
"validate:theme-catalog": "node --experimental-strip-types bin/validate-theme-catalog.mts",
@@ -171,7 +164,6 @@
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"plugins-service": "workspace:*",
"promise-retry": "^2.0.1",
"re-resizable": "^6.11.2",
"react": "19.2.4",

View File

@@ -27,13 +27,6 @@ let worker = null;
// The regular Worker running the backend, created only on the leader tab
let localBackendWorker = null;
function terminateLocalBackendWorker() {
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
}
/**
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
* (onmessage, postMessage, addEventListener, start) to the connection layer.
@@ -50,22 +43,9 @@ class WorkerBridge {
this._onmessage = null;
this._listeners = [];
this._started = false;
this._isInitialized = false;
this._currentBudgetId = null;
this._wasHidden = document.visibilityState === 'hidden';
this._onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this._wasHidden = true;
} else if (this._wasHidden) {
this._wasHidden = false;
this._resumeAssociation();
}
};
// Listen for all messages from the SharedWorker port
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
document.addEventListener('visibilitychange', this._onVisibilityChange);
}
set onmessage(handler) {
@@ -129,7 +109,10 @@ class WorkerBridge {
// show-budgets normally.
if (msg && msg.type === '__close-and-transfer') {
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
this._applyRole('UNASSIGNED', null);
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
// Only dispatch a synthetic reply if there's an actual close-budget
// request to complete. When requestId is null the eviction was
// triggered externally (e.g. another tab deleted this budget).
@@ -143,7 +126,6 @@ class WorkerBridge {
// Role change notification
if (msg && msg.type === '__role-change') {
this._applyRole(msg.role, msg.budgetId ?? null);
console.log(
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
);
@@ -164,47 +146,13 @@ class WorkerBridge {
}
// Everything else goes to the connection layer
if (msg && msg.type === 'push' && msg.name === 'show-budgets') {
this._applyRole('UNASSIGNED', null);
}
this._dispatch(event);
}
markInitialized() {
this._isInitialized = true;
}
_normalizeBudgetId(budgetId) {
if (
typeof budgetId === 'string' &&
budgetId.length > 0 &&
!budgetId.startsWith('__')
) {
return budgetId;
}
return null;
}
_applyRole(role, budgetId) {
this._currentBudgetId = this._normalizeBudgetId(budgetId);
if (role !== 'LEADER') {
terminateLocalBackendWorker();
}
}
_resumeAssociation() {
if (!this._isInitialized) {
return;
}
this._sharedPort.postMessage({
type: '__resume-tab',
budgetId: this._currentBudgetId,
});
}
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
terminateLocalBackendWorker();
if (localBackendWorker) {
localBackendWorker.terminate();
}
localBackendWorker = new Worker(backendWorkerUrl);
initSQLBackend(localBackendWorker);
@@ -290,12 +238,10 @@ function createBackendWorker() {
'SharedArrayBufferOverride',
),
});
worker.markInitialized();
const notifyTabClosing = () => {
window.addEventListener('beforeunload', () => {
sharedPort.postMessage({ type: 'tab-closing' });
};
window.addEventListener('beforeunload', notifyTabClosing);
});
return;
} catch (e) {
@@ -335,17 +281,10 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
// Skip SW registration in dev so stale cached assets don't override edits
// between page loads. Plugin code that needs a SW can register one itself.
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
// SW lifecycle to swap the page — fall back to a plain reload so callers
// don't hang on the never-resolving promise inside applyAppUpdate.
const updateSW = IS_DEV
? () => window.location.reload()
: registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,

View File

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

View File

@@ -44,19 +44,13 @@ function makeSchedule(
} satisfies ScheduleEntity;
}
function mockedSchedules(schedules: ScheduleEntity[]) {
return {
isLoading: false,
schedules,
statuses: new Map(),
statusLabels: new Map(),
};
}
describe('SelectedBalance normal transactions', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useCachedSchedules).mockReturnValue(mockedSchedules([]));
vi.mocked(useCachedSchedules).mockReturnValue({
isLoading: false,
schedules: [],
});
});
test('shows balance for selected normal transactions', () => {
@@ -99,9 +93,10 @@ describe('SelectedBalance preview (scheduled) transactions', () => {
vi.mocked(useSelectedItems).mockReturnValue(
new Set([`preview/${scheduleId}/2026-03-24`]),
);
vi.mocked(useCachedSchedules).mockReturnValue(
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
);
vi.mocked(useCachedSchedules).mockReturnValue({
isLoading: false,
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
});
render(
<TestProviders>
@@ -121,9 +116,10 @@ describe('SelectedBalance preview (scheduled) transactions', () => {
const selectedItems = new Set([previewId1, previewId2]);
vi.mocked(useSelectedItems).mockReturnValue(selectedItems);
vi.mocked(useCachedSchedules).mockReturnValue(
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
);
vi.mocked(useCachedSchedules).mockReturnValue({
isLoading: false,
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
});
render(
<TestProviders>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,76 +0,0 @@
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import type { Action } from './actions';
import type { ReducerState } from './constants';
import { BySaveAutomation } from './editor/BySaveAutomation';
import { FixedAutomation } from './editor/FixedAutomation';
import { HistoricalAutomation } from './editor/HistoricalAutomation';
import { LimitAutomation } from './editor/LimitAutomation';
import { PercentageAutomation } from './editor/PercentageAutomation';
import { RefillAutomation } from './editor/RefillAutomation';
import { RemainderAutomation } from './editor/RemainderAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
type ActiveEditorProps = {
state: ReducerState;
dispatch: (action: Action) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation: boolean;
onAddLimitAutomation: () => void;
};
export function ActiveEditor({
state,
dispatch,
schedules,
categories,
hasLimitAutomation,
onAddLimitAutomation,
}: ActiveEditorProps) {
switch (state.displayType) {
case 'limit':
return <LimitAutomation template={state.template} dispatch={dispatch} />;
case 'refill':
return (
<RefillAutomation
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
);
case 'fixed':
return <FixedAutomation template={state.template} dispatch={dispatch} />;
case 'schedule':
return (
<ScheduleAutomation
schedules={schedules}
template={state.template}
dispatch={dispatch}
/>
);
case 'percentage':
return (
<PercentageAutomation
dispatch={dispatch}
template={state.template}
categories={categories}
/>
);
case 'historical':
return (
<HistoricalAutomation template={state.template} dispatch={dispatch} />
);
case 'by':
return <BySaveAutomation template={state.template} dispatch={dispatch} />;
case 'remainder':
return (
<RemainderAutomation template={state.template} dispatch={dispatch} />
);
default:
state satisfies never;
return null;
}
}

View File

@@ -16,17 +16,14 @@ import { FormField, FormLabel, FormTextLabel } from '#components/forms';
import { setType } from './actions';
import type { Action } from './actions';
import type { ReducerState } from './constants';
import { displayTemplateTypes } from './constants';
import { getDisplayTemplateMeta } from './displayTemplateMeta';
import { BySaveAutomation } from './editor/BySaveAutomation';
import { FixedAutomation } from './editor/FixedAutomation';
import type { ReducerState } from './constants';
import { HistoricalAutomation } from './editor/HistoricalAutomation';
import { LimitAutomation } from './editor/LimitAutomation';
import { PercentageAutomation } from './editor/PercentageAutomation';
import { RefillAutomation } from './editor/RefillAutomation';
import { RemainderAutomation } from './editor/RemainderAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
import { WeekAutomation } from './editor/WeekAutomation';
type BudgetAutomationEditorProps = {
inline: boolean;
@@ -53,7 +50,7 @@ const displayTypeToDescription = {
automation.
</Trans>
),
fixed: (
week: (
<Trans>
Add a fixed amount to this category for each week in the month. For
example, $100 per week would be $400 per month in a 4-week month.
@@ -83,18 +80,6 @@ const displayTypeToDescription = {
to account for seasonal changes.
</Trans>
),
by: (
<Trans>
Spread a target amount across the months between now and a target date.
Useful for annual goals or saving toward a one-off expense.
</Trans>
),
remainder: (
<Trans>
Split any remaining To Budget across categories using this automation.
Higher weights take a larger share of the leftover funds.
</Trans>
),
};
export function BudgetAutomationEditor({
@@ -123,9 +108,9 @@ export function BudgetAutomationEditor({
/>
);
break;
case 'fixed':
case 'week':
automationEditor = (
<FixedAutomation template={state.template} dispatch={dispatch} />
<WeekAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'schedule':
@@ -151,16 +136,6 @@ export function BudgetAutomationEditor({
<HistoricalAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'by':
automationEditor = (
<BySaveAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'remainder':
automationEditor = (
<RemainderAutomation template={state.template} dispatch={dispatch} />
);
break;
default:
state satisfies never;
automationEditor = (
@@ -190,10 +165,7 @@ export function BudgetAutomationEditor({
<InitialFocus>
<Select
id="type-field"
options={displayTemplateTypes.map(type => [
type,
getDisplayTemplateMeta(type).label,
])}
options={displayTemplateTypes}
defaultLabel={t('Select an option')}
value={state.displayType}
onChange={type => type && dispatch(setType(type))}

View File

@@ -14,14 +14,12 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { ReducerState } from './constants';
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
import { WeekAutomationReadOnly } from './editor/WeekAutomationReadOnly';
type BudgetAutomationReadOnlyProps = {
state: ReducerState;
@@ -54,10 +52,8 @@ export function BudgetAutomationReadOnly({
case 'refill':
automationReadOnly = <RefillAutomationReadOnly />;
break;
case 'fixed':
automationReadOnly = (
<FixedAutomationReadOnly template={state.template} />
);
case 'week':
automationReadOnly = <WeekAutomationReadOnly template={state.template} />;
break;
case 'schedule':
automationReadOnly = (
@@ -77,18 +73,7 @@ export function BudgetAutomationReadOnly({
<HistoricalAutomationReadOnly template={state.template} />
);
break;
case 'by':
automationReadOnly = (
<BySaveAutomationReadOnly template={state.template} />
);
break;
case 'remainder':
automationReadOnly = (
<RemainderAutomationReadOnly template={state.template} />
);
break;
default:
state satisfies never;
automationReadOnly = (
<Text>
<Trans>Unrecognized automation type.</Trans>

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -8,9 +8,7 @@ import { theme } from '@actual-app/components/theme';
import type { CategoryEntity } from '@actual-app/core/types/models';
import { css, cx } from '@emotion/css';
import { MonthsContext } from '#components/budget/MonthsContext';
import { useFeatureFlag } from '#hooks/useFeatureFlag';
import { useSyncedPref } from '#hooks/useSyncedPref';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
@@ -32,24 +30,15 @@ export function CategoryAutomationButton({
}: CategoryAutomationButtonProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const monthsContext = useContext(MonthsContext);
const month = monthsContext?.months?.[0];
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const hasAutomations = !!category.goal_def?.length;
if (!goalTemplatesEnabled || !goalTemplatesUIEnabled) {
return null;
}
// Income categories don't accept templates in envelope budgets (only the
// tracking budget runs templates against income categories).
if (category.is_income && budgetType !== 'tracking') {
return null;
}
return (
<Button
variant="bare"
@@ -70,7 +59,7 @@ export function CategoryAutomationButton({
pushModal({
modal: {
name: 'category-automations-edit',
options: { categoryId: category.id, month },
options: { categoryId: category.id },
},
}),
);

View File

@@ -1,62 +0,0 @@
import { Trans } from 'react-i18next';
import type { Template } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
type TemplateSentenceProps = {
template: Template;
categoryNameMap: Record<string, string>;
};
export function TemplateSentence({
template,
categoryNameMap,
}: TemplateSentenceProps) {
switch (template.type) {
case 'limit':
return <LimitAutomationReadOnly template={template} />;
case 'refill':
return <RefillAutomationReadOnly />;
case 'periodic':
return <FixedAutomationReadOnly template={template} />;
case 'schedule':
return <ScheduleAutomationReadOnly template={template} />;
case 'percentage':
return (
<PercentageAutomationReadOnly
template={template}
categoryNameMap={categoryNameMap}
/>
);
case 'average':
case 'copy':
return <HistoricalAutomationReadOnly template={template} />;
case 'by':
return <BySaveAutomationReadOnly template={template} />;
case 'remainder':
return <RemainderAutomationReadOnly template={template} />;
case 'simple':
case 'spend':
case 'goal':
case 'error': {
const type = template.type;
return (
<Trans>
Unsupported template type: {{ type } satisfies TransObjectLiteral}
</Trans>
);
}
default:
template satisfies never;
return null;
}
}

View File

@@ -1,84 +0,0 @@
import {
addMonths,
dayFromDate,
firstDayOfMonth,
monthFromDate,
} from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import uniqueId from 'lodash/uniqueId';
import type { DisplayTemplateType } from './constants';
import { DEFAULT_PRIORITY } from './reducer';
export type AutomationEntry = {
id: string;
template: Template;
displayType: DisplayTemplateType;
};
export function createAutomationEntry(
template: Template,
displayType: DisplayTemplateType,
): AutomationEntry {
return {
id: uniqueId('automation-'),
template,
displayType,
};
}
export type AutomationExample = {
displayType: DisplayTemplateType;
create: () => AutomationEntry;
};
export function getAutomationExamples(): AutomationExample[] {
return [
{
displayType: 'fixed',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'periodic',
amount: 100,
period: { period: 'month', amount: 1 },
starting: dayFromDate(firstDayOfMonth(new Date())),
priority: DEFAULT_PRIORITY,
},
'fixed',
),
},
{
displayType: 'by',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'by',
amount: 1200,
// Always 12 months out so users in late-year months don't get a
// target that's already passed.
month: addMonths(monthFromDate(new Date()), 12),
annual: true,
repeat: 1,
priority: DEFAULT_PRIORITY,
},
'by',
),
},
{
displayType: 'schedule',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'schedule',
name: '',
priority: DEFAULT_PRIORITY,
},
'schedule',
),
},
];
}

View File

@@ -1,178 +0,0 @@
import { Trans } from 'react-i18next';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { formatMonthLabel } from './formatMonthLabel';
import type {
AutomationErrorKind,
GlobalConflictKind,
} from './validateAutomation';
export function AutomationErrorTitle({
error,
}: {
error: AutomationErrorKind;
}) {
switch (error.kind) {
case 'schedule-not-found':
return <Trans>Schedule not found</Trans>;
case 'refill-no-cap':
return <Trans>Refill needs a balance cap</Trans>;
case 'percentage-out-of-range':
return <Trans>Percentage out of range</Trans>;
case 'percentage-no-source':
return <Trans>Source category missing</Trans>;
case 'by-no-month':
return <Trans>Target month missing</Trans>;
case 'by-target-past':
return <Trans>Target is in the past</Trans>;
case 'percentage-source-not-found':
return <Trans>Source category not recognised</Trans>;
default:
error satisfies never;
return null;
}
}
export function AutomationErrorShort({
error,
}: {
error: AutomationErrorKind;
}) {
const locale = useLocale();
switch (error.kind) {
case 'schedule-not-found':
return error.name ? (
<Trans>No schedule named &ldquo;{{ name: error.name }}&rdquo;</Trans>
) : (
<Trans>Pick a schedule</Trans>
);
case 'refill-no-cap':
return <Trans>Add a balance cap above</Trans>;
case 'percentage-out-of-range':
return (
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
);
case 'percentage-no-source':
return <Trans>Pick a source category</Trans>;
case 'by-no-month':
return <Trans>Pick a target month</Trans>;
case 'by-target-past':
return (
<Trans>
{{ month: formatMonthLabel(error.month, locale) }} has already passed
</Trans>
);
case 'percentage-source-not-found':
return <Trans>Pick a valid income category</Trans>;
default:
error satisfies never;
return null;
}
}
export function AutomationErrorDetail({
error,
}: {
error: AutomationErrorKind;
}) {
switch (error.kind) {
case 'schedule-not-found':
return (
<Trans>
Pick an existing schedule, or create one in Schedules. This automation
can&rsquo;t run until it&rsquo;s linked to a schedule.
</Trans>
);
case 'refill-no-cap':
return (
<Trans>
Refill automations must have a &ldquo;Balance cap&rdquo; automation
added to use as the target.
</Trans>
);
case 'percentage-out-of-range':
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
case 'percentage-no-source':
return (
<Trans>
Percentage automations need a source category to calculate against.
</Trans>
);
case 'by-no-month':
return (
<Trans>
Goals by date need a target month. Pick when you want this fully
funded.
</Trans>
);
case 'by-target-past':
return (
<Trans>
Pick a future month, or switch to a recurring annual goal to keep
saving.
</Trans>
);
case 'percentage-source-not-found':
return (
<Trans>
The selected source &ldquo;{{ source: error.source }}&rdquo; is not a
known income category.
</Trans>
);
default:
error satisfies never;
return null;
}
}
export function GlobalConflictTitle({
conflict,
}: {
conflict: GlobalConflictKind;
}) {
switch (conflict.kind) {
case 'over-income':
return <Trans>Automations will demand more than income</Trans>;
case 'percent-over-100':
return (
<Trans>
Percent automations total {{ total: Math.round(conflict.total) }}% of
income
</Trans>
);
default:
conflict satisfies never;
return null;
}
}
export function GlobalConflictDetail({
conflict,
}: {
conflict: GlobalConflictKind;
}) {
const format = useFormat();
switch (conflict.kind) {
case 'over-income':
return (
<Trans>
This month&rsquo;s automations ask for around{' '}
{{ total: format(conflict.total, 'financial') }} but only{' '}
{{ income: format(conflict.income, 'financial') }} is available to
budget. Lower amounts or switch one to &ldquo;Whatever is left&rdquo;.
</Trans>
);
case 'percent-over-100':
return (
<Trans>
Your percent automations add up to more than 100% and will be capped
at 100%.
</Trans>
);
default:
conflict satisfies never;
return null;
}
}

View File

@@ -1,27 +1,23 @@
import type {
AverageTemplate,
ByTemplate,
CopyTemplate,
LimitTemplate,
PercentageTemplate,
PeriodicTemplate,
RefillTemplate,
RemainderTemplate,
ScheduleTemplate,
} from '@actual-app/core/types/models/templates';
export const displayTemplateTypes = [
'fixed',
'schedule',
'by',
'percentage',
'historical',
'limit',
'refill',
'remainder',
] as const;
['limit', 'Balance limit'] as const,
['refill', 'Refill'] as const,
['week', 'Fixed (weekly)'] as const,
['schedule', 'Existing schedule'] as const,
['percentage', 'Percent of category'] as const,
['historical', 'Copy past budgets'] as const,
];
export type DisplayTemplateType = (typeof displayTemplateTypes)[number];
export type DisplayTemplateType = (typeof displayTemplateTypes)[number][0];
export type ReducerState =
| {
@@ -34,7 +30,7 @@ export type ReducerState =
}
| {
template: PeriodicTemplate;
displayType: 'fixed';
displayType: 'week';
}
| {
template: ScheduleTemplate;
@@ -47,12 +43,4 @@ export type ReducerState =
| {
template: CopyTemplate | AverageTemplate;
displayType: 'historical';
}
| {
template: ByTemplate;
displayType: 'by';
}
| {
template: RemainderTemplate;
displayType: 'remainder';
};

View File

@@ -1,91 +0,0 @@
import type { ComponentType, SVGProps } from 'react';
import {
SvgChartPie,
SvgEquals,
SvgMoneyBag,
SvgPiggyBank,
SvgShare,
SvgTime,
} from '@actual-app/components/icons/v1';
import {
SvgArrowsSynchronize,
SvgCalendar3,
} from '@actual-app/components/icons/v2';
import { t } from 'i18next';
import type { DisplayTemplateType } from './constants';
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
export type DisplayTemplateMeta = {
label: string;
description: string;
icon: IconComponent;
};
export function getDisplayTemplateMeta(
displayType: DisplayTemplateType,
): DisplayTemplateMeta {
switch (displayType) {
case 'fixed':
return {
label: t('Fixed amount'),
description: t('Add a set amount every month, week, day, or year.'),
icon: SvgPiggyBank,
};
case 'schedule':
return {
label: t('Cover schedule'),
description: t('Save up for a recurring scheduled transaction.'),
icon: SvgCalendar3,
};
case 'by':
return {
label: t('Save by date'),
description: t(
'Spread a target amount across the months until a deadline.',
),
icon: SvgMoneyBag,
};
case 'percentage':
return {
label: t('% of income'),
description: t("A share of this month's or last month's income."),
icon: SvgChartPie,
};
case 'historical':
return {
label: t('From history'),
description: t(
'Use past months: average, a specific month, or a copy.',
),
icon: SvgTime,
};
case 'limit':
return {
label: t('Balance cap'),
description: t('Never let the category balance exceed a cap.'),
icon: SvgEquals,
};
case 'refill':
return {
label: t('Refill to cap'),
description: t(
'Top the category back up to the balance cap each month.',
),
icon: SvgArrowsSynchronize,
};
case 'remainder':
return {
label: t('Whatever is left'),
description: t(
'Split any remaining To Budget across these categories.',
),
icon: SvgShare,
};
default:
displayType satisfies never;
throw new Error(`Unknown display type: ${String(displayType)}`);
}
}

View File

@@ -1,116 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type { ByTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { GenericInput } from '#components/util/GenericInput';
import { useFormat } from '#hooks/useFormat';
type BySaveAutomationProps = {
template: ByTemplate;
dispatch: (action: Action) => void;
};
export const BySaveAutomation = ({
template,
dispatch,
}: BySaveAutomationProps) => {
const { t } = useTranslation();
const format = useFormat();
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const committedRepeat = template.repeat ?? 1;
const [rawRepeat, setRawRepeat] = useState(String(committedRepeat));
useEffect(() => {
setRawRepeat(String(committedRepeat));
}, [committedRepeat]);
const commitRepeat = () => {
const parsed = Math.max(1, Math.trunc(Number(rawRepeat)) || 1);
setRawRepeat(String(parsed));
if (parsed !== committedRepeat) {
dispatch(updateTemplate({ type: 'by', repeat: parsed }));
}
};
return (
<>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Total amount')} htmlFor="by-amount-field" />
<AmountInput
id="by-amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'by',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Target date')} htmlFor="by-month-field" />
<GenericInput
type="date"
field="date"
value={template.month ? `${template.month}-01` : ''}
onChange={(value: string) =>
dispatch(
updateTemplate({
type: 'by',
month: value ? value.slice(0, 7) : '',
}),
)
}
/>
</FormField>
</SpaceBetween>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Repeat every')}
htmlFor="by-repeat-amount-field"
/>
<Input
id="by-repeat-amount-field"
type="number"
min={1}
step={1}
value={rawRepeat}
onChangeValue={setRawRepeat}
onBlur={commitRepeat}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Period')} htmlFor="by-period-field" />
<Select
id="by-period-field"
value={template.annual ? 'year' : 'month'}
onChange={value =>
dispatch(updateTemplate({ type: 'by', annual: value === 'year' }))
}
options={[
['month', t('Months')],
['year', t('Years')],
]}
/>
</FormField>
</SpaceBetween>
</>
);
};

View File

@@ -1,52 +0,0 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type { ByTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
type BySaveAutomationReadOnlyProps = {
template: ByTemplate;
};
export const BySaveAutomationReadOnly = ({
template,
}: BySaveAutomationReadOnlyProps) => {
const format = useFormat();
const locale = useLocale();
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const month = formatMonthLabel(template.month, locale);
const repeat = template.repeat ?? 1;
if (template.annual) {
return (
<Trans count={repeat}>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
by {{ month }}, repeating every {{ count: repeat }} years
</Trans>
);
}
if (template.repeat && template.repeat > 0) {
return (
<Trans count={repeat}>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
by {{ month }}, repeating every {{ count: repeat }} months
</Trans>
);
}
return (
<Trans>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText> by{' '}
{{ month }}
</Trans>
);
};

View File

@@ -1,124 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { GenericInput } from '#components/util/GenericInput';
import { useFormat } from '#hooks/useFormat';
type FixedAutomationProps = {
template: PeriodicTemplate;
dispatch: (action: Action) => void;
};
type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export const FixedAutomation = ({
template,
dispatch,
}: FixedAutomationProps) => {
const { t } = useTranslation();
const periodUnitOptions: Array<[PeriodUnit, string]> = [
['day', t('days')],
['week', t('weeks')],
['month', t('months')],
['year', t('years')],
];
const format = useFormat();
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const periodUnit = template.period?.period ?? 'month';
const periodAmount = template.period?.amount ?? 1;
const [rawPeriodAmount, setRawPeriodAmount] = useState(String(periodAmount));
// Resync when a different automation row is selected (the component
// instance is reused across rows).
useEffect(() => {
setRawPeriodAmount(String(periodAmount));
}, [periodAmount]);
const commitPeriodAmount = () => {
const parsed = Math.max(1, Math.trunc(Number(rawPeriodAmount)) || 1);
setRawPeriodAmount(String(parsed));
if (parsed !== periodAmount) {
dispatch(
updateTemplate({
type: 'periodic',
period: { period: periodUnit, amount: parsed },
}),
);
}
};
return (
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'periodic',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Every')} htmlFor="period-amount-field" />
<Input
id="period-amount-field"
type="number"
min={1}
step={1}
value={rawPeriodAmount}
onChangeValue={setRawPeriodAmount}
onBlur={commitPeriodAmount}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Period')} htmlFor="period-unit-field" />
<Select
id="period-unit-field"
value={periodUnit}
onChange={value =>
dispatch(
updateTemplate({
type: 'periodic',
period: {
period: value,
amount: periodAmount,
},
}),
)
}
options={periodUnitOptions}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Starting')} htmlFor="starting-field" />
<GenericInput
type="date"
field="date"
value={template.starting ?? ''}
onChange={(value: string) =>
dispatch(updateTemplate({ type: 'periodic', starting: value }))
}
/>
</FormField>
</SpaceBetween>
);
};

View File

@@ -1,61 +0,0 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
type FixedAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
export function FixedAutomationReadOnly({
template,
}: FixedAutomationReadOnlyProps) {
const format = useFormat();
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const periodAmount = template.period?.amount ?? 1;
const periodUnit = template.period?.period ?? 'month';
switch (periodUnit) {
case 'day':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} days
</Trans>
);
case 'week':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} weeks
</Trans>
);
case 'month':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} months
</Trans>
);
case 'year':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} years
</Trans>
);
default:
return null;
}
}

View File

@@ -13,12 +13,12 @@ export const HistoricalAutomationReadOnly = ({
template,
}: HistoricalAutomationReadOnlyProps) => {
return template.type === 'copy' ? (
<Trans count={template.lookBack}>
Budget the same amount as {{ count: template.lookBack }} months ago
<Trans>
Budget the same amount as {{ amount: template.lookBack }} months ago
</Trans>
) : (
<Trans count={template.numMonths}>
Budget the average of the last {{ count: template.numMonths }} months
<Trans>
Budget the average of the last {{ amount: template.numMonths }} months
</Trans>
);
};

View File

@@ -1,8 +1,9 @@
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
currentDate,
dayFromDate,
@@ -17,7 +18,6 @@ import { setDay } from 'date-fns/setDay';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { LabeledCheckbox } from '#components/forms/LabeledCheckbox';
import { AmountInput } from '#components/util/AmountInput';
import { useDaysOfWeek } from '#hooks/useDaysOfWeek';
import { useFormat } from '#hooks/useFormat';
@@ -115,21 +115,26 @@ export const LimitAutomation = ({
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
{period === 'weekly' && amountField}
<FormField key="hold-overflow-field" style={{ flex: 1 }}>
<LabeledCheckbox
id="hold-overflow-field"
checked={!!hold}
onChange={e =>
dispatch(
updateTemplate({ type: 'limit', hold: e.target.checked }),
)
<FormField key="excess-funds-field" style={{ flex: 1 }}>
<FormLabel
title={t('Excess funds mode')}
htmlFor="excess-funds-field"
/>
<Select
id="excess-funds-field"
value={hold}
onChange={value =>
dispatch(updateTemplate({ type: 'limit', hold: value }))
}
>
<span style={{ marginLeft: 6, fontSize: 12, whiteSpace: 'nowrap' }}>
<Trans>Retain existing funds over the cap</Trans>
</span>
</LabeledCheckbox>
options={[
[false, t('Remove all funds over the limit')],
[true, t('Retain any funds over the limit')],
]}
className={selectButtonClassName}
/>
</FormField>
{period !== 'weekly' && <View style={{ flex: 1 }} />}
</SpaceBetween>
</>
);

View File

@@ -44,7 +44,7 @@ export const PercentageAutomation = ({
? categories.map(group => ({
...group,
categories: group.categories?.filter(
category => category.id !== 'available funds',
category => category.id !== 'to-budget',
),
}))
: categories
@@ -87,7 +87,7 @@ export const PercentageAutomation = ({
updateTemplate({
type: 'percentage',
previous,
...(previous && template.category === 'available funds'
...(previous && template.category === 'to-budget'
? { category: '' }
: {}),
}),

View File

@@ -13,7 +13,7 @@ export const PercentageAutomationReadOnly = ({
}: PercentageAutomationReadOnlyProps) => {
const { t } = useTranslation();
if (template.category === 'all income') {
if (template.category === 'total') {
return template.previous ? (
<Trans>
Budget {{ percent: template.percent }}% of total income last month
@@ -25,7 +25,7 @@ export const PercentageAutomationReadOnly = ({
);
}
if (template.category === 'available funds') {
if (template.category === 'to-budget') {
return template.previous ? (
<Trans>
Budget {{ percent: template.percent }}% of available funds to budget

View File

@@ -1,62 +0,0 @@
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
type RemainderAutomationProps = {
template: RemainderTemplate;
dispatch: (action: Action) => void;
};
export const RemainderAutomation = ({
template,
dispatch,
}: RemainderAutomationProps) => {
const { t } = useTranslation();
const committedWeight = template.weight ?? 1;
// Track the raw input so the user can clear and retype without the field
// snapping back. Commit (and clamp) on blur.
const [rawWeight, setRawWeight] = useState(String(committedWeight));
useEffect(() => {
setRawWeight(String(committedWeight));
}, [committedWeight]);
const commitWeight = () => {
const parsed = Math.max(1, Math.trunc(Number(rawWeight)) || 1);
setRawWeight(String(parsed));
if (parsed !== committedWeight) {
dispatch(updateTemplate({ type: 'remainder', weight: parsed }));
}
};
return (
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Weight')} htmlFor="remainder-weight-field" />
<Input
id="remainder-weight-field"
type="number"
min={1}
step={1}
value={rawWeight}
onChangeValue={setRawWeight}
onBlur={commitWeight}
/>
</FormField>
<Text style={{ flex: 2, color: theme.pageTextSubdued, fontSize: 12 }}>
<Trans>
Categories with higher weights get a bigger share of the leftover To
Budget.
</Trans>
</Text>
</SpaceBetween>
);
};

View File

@@ -1,18 +0,0 @@
import { Trans } from 'react-i18next';
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
type RemainderAutomationReadOnlyProps = {
template: RemainderTemplate;
};
export const RemainderAutomationReadOnly = ({
template,
}: RemainderAutomationReadOnlyProps) => {
return (
<Trans>
Share remaining funds to budget (weight {{ weight: template.weight ?? 1 }}
)
</Trans>
);
};

View File

@@ -23,16 +23,8 @@ export const ScheduleAutomation = ({
dispatch,
}: ScheduleAutomationProps) => {
const { t } = useTranslation();
// Match the filter applied to the Select options below — completed and
// tombstoned schedules aren't selectable, so a category whose only
// schedules are completed should fall through to the "no schedules" state
// instead of showing an empty picker.
const selectableSchedules = schedules.filter(
(s): s is typeof s & { name: string } =>
!!s.name && !s.completed && !s.tombstone,
);
return selectableSchedules.length ? (
return schedules.length ? (
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Schedule')} htmlFor="schedule-field" />
@@ -49,7 +41,9 @@ export const ScheduleAutomation = ({
}),
)
}
options={selectableSchedules.map(s => [s.name, s.name] as const)}
options={schedules.flatMap(schedule =>
schedule.name ? [[schedule.name, schedule.name]] : [],
)}
/>
</FormField>
<FormField style={{ flex: 1 }}>

View File

@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
type WeekAutomationProps = {
template: PeriodicTemplate;
dispatch: (action: Action) => void;
};
export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
const { t } = useTranslation();
return (
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
key="amount-input"
value={template.amount ?? 0}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'periodic',
amount: value,
}),
)
}
/>
</FormField>
);
};

View File

@@ -0,0 +1,31 @@
import { Trans } from 'react-i18next';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
type WeekAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
export const WeekAutomationReadOnly = ({
template,
}: WeekAutomationReadOnlyProps) => {
const format = useFormat();
return (
<Trans>
Budget{' '}
<FinancialText>
{
{
amount: format(template.amount, 'financial'),
} as TransObjectLiteral
}
</FinancialText>{' '}
each week
</Trans>
);
};

View File

@@ -1,14 +0,0 @@
import * as monthUtils from '@actual-app/core/shared/months';
// Format a YYYY-MM string as "MMM yyyy" using the active locale (matching
// the convention used elsewhere in the codebase via monthUtils.format).
// Falls back to the raw input if it doesn't look like YYYY-MM, and to "—"
// for empty/missing values so callers don't need their own guards.
export function formatMonthLabel(
month: string | undefined | null,
locale?: Parameters<typeof monthUtils.format>[2],
): string {
if (!month) return '—';
if (!monthUtils.isValidYearMonth(month)) return month;
return monthUtils.format(`${month}-01`, 'MMM yyyy', locale);
}

View File

@@ -1,9 +1,4 @@
import {
addMonths,
dayFromDate,
firstDayOfMonth,
monthFromDate,
} from '@actual-app/core/shared/months';
import { firstDayOfMonth } from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import type { Action } from './actions';
@@ -30,7 +25,7 @@ export const getInitialState = (template: Template | null): ReducerState => {
priority: template.priority,
directive: template.directive,
},
displayType: 'fixed',
displayType: 'week',
};
case 'percentage':
return {
@@ -45,20 +40,13 @@ export const getInitialState = (template: Template | null): ReducerState => {
case 'periodic':
return {
template,
displayType: 'fixed',
displayType: 'week',
};
case 'spend':
throw new Error('Goal is not yet supported');
case 'by':
return {
template,
displayType: 'by',
};
throw new Error('Goal is not yet supported');
case 'remainder':
return {
template,
displayType: 'remainder',
};
throw new Error('Remainder is not yet supported');
case 'limit':
return {
template,
@@ -129,7 +117,7 @@ const changeType = (
type: 'percentage',
percent: 15,
previous: false,
category: 'all income',
category: 'total',
priority: DEFAULT_PRIORITY,
},
};
@@ -146,7 +134,7 @@ const changeType = (
priority: DEFAULT_PRIORITY,
},
};
case 'fixed':
case 'week':
if (prevState.template.type === 'periodic') {
return prevState;
}
@@ -155,12 +143,12 @@ const changeType = (
template: {
directive: 'template',
type: 'periodic',
amount: 100,
amount: 5,
period: {
period: 'month',
period: 'week',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
starting: '',
priority: DEFAULT_PRIORITY,
},
};
@@ -180,35 +168,6 @@ const changeType = (
priority: DEFAULT_PRIORITY,
},
};
case 'by':
if (prevState.template.type === 'by') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'by',
amount: 1200,
month: addMonths(monthFromDate(new Date()), 12),
annual: true,
repeat: 1,
priority: DEFAULT_PRIORITY,
},
};
case 'remainder':
if (prevState.template.type === 'remainder') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'remainder',
weight: 1,
priority: null,
},
};
default:
// Make sure we're not missing any cases
throw new Error(

View File

@@ -7,24 +7,21 @@ export function useBudgetAutomationCategories() {
const { t } = useTranslation();
const { data: { grouped } = { grouped: [] } } = useCategories();
const categories = useMemo(() => {
const incomeGroups = grouped.filter(group => group.is_income);
const incomeGroup = grouped.filter(group => group.name === 'Income')[0];
return [
{
id: '',
name: t('Special categories'),
categories: [
{ id: 'all income', group: '', name: t('Total of all income') },
{ id: 'total', group: '', name: t('Total of all income') },
{
id: 'available funds',
id: 'to-budget',
group: '',
name: t('Available funds to budget'),
},
],
},
...incomeGroups.map(group => ({
...group,
name: t('Income categories'),
})),
{ ...incomeGroup, name: t('Income categories') },
];
}, [grouped, t]);

View File

@@ -1,69 +0,0 @@
import type { Template } from '@actual-app/core/types/models/templates';
import { describe, expect, it } from 'vitest';
import { validatePercentageAllocation } from './validateAutomation';
function percent(
category: string,
percent: number,
previous = false,
): Template {
return {
type: 'percentage',
percent,
previous,
category,
directive: 'template',
priority: 1,
};
}
describe('validatePercentageAllocation', () => {
it('returns null when no percentage templates are present', () => {
expect(validatePercentageAllocation([])).toBeNull();
});
it('flags a single source over 100%', () => {
expect(
validatePercentageAllocation([
percent('Salary', 60),
percent('Salary', 50),
]),
).toEqual({ kind: 'percent-over-100', total: 110 });
});
it('does not sum across distinct income sources', () => {
expect(
validatePercentageAllocation([
percent('Income-HSA', 100, true),
percent('Interest-HSA', 100),
]),
).toBeNull();
});
it('treats this-month and last-month income as different sources', () => {
expect(
validatePercentageAllocation([
percent('Salary', 100, false),
percent('Salary', 100, true),
]),
).toBeNull();
});
it('ignores templates with a missing source', () => {
const orphan = {
...percent('Salary', 100),
category: null as unknown as string,
};
expect(validatePercentageAllocation([orphan])).toBeNull();
});
it('matches sources case-insensitively', () => {
expect(
validatePercentageAllocation([
percent('Salary', 60),
percent('salary', 50),
]),
).toEqual({ kind: 'percent-over-100', total: 110 });
});
});

View File

@@ -1,110 +0,0 @@
import * as monthUtils from '@actual-app/core/shared/months';
import type { ScheduleEntity } from '@actual-app/core/types/models';
import type { Template } from '@actual-app/core/types/models/templates';
import type { DisplayTemplateType } from './constants';
export type AutomationErrorKind =
| { kind: 'schedule-not-found'; name: string }
| { kind: 'refill-no-cap' }
| { kind: 'percentage-out-of-range'; percent: number }
| { kind: 'percentage-no-source' }
| { kind: 'percentage-source-not-found'; source: string }
| { kind: 'by-no-month' }
| { kind: 'by-target-past'; month: string };
export type GlobalConflictKind =
| { kind: 'over-income'; total: number; income: number }
| { kind: 'percent-over-100'; total: number };
export function validateAutomation(
template: Template,
displayType: DisplayTemplateType,
allTemplates: readonly Template[],
schedules: readonly ScheduleEntity[],
today: Date,
// Set of recognised percentage sources (income category ids, lower-cased
// category names, and special source aliases like 'all income'). When
// omitted the source-not-found check is skipped (the engine still validates
// server-side at apply time).
validPercentageSources?: ReadonlySet<string>,
): AutomationErrorKind | null {
switch (displayType) {
case 'schedule':
if (template.type !== 'schedule') return null;
if (!template.name) return { kind: 'schedule-not-found', name: '' };
if (
!schedules.some(
s => s.name === template.name && !s.completed && !s.tombstone,
)
) {
return { kind: 'schedule-not-found', name: template.name };
}
return null;
case 'refill':
if (!allTemplates.some(t => t.type === 'limit')) {
return { kind: 'refill-no-cap' };
}
return null;
case 'percentage':
if (template.type !== 'percentage') return null;
if (!template.category) return { kind: 'percentage-no-source' };
if (template.percent <= 0 || template.percent > 100) {
return {
kind: 'percentage-out-of-range',
percent: template.percent,
};
}
if (
validPercentageSources &&
!validPercentageSources.has(template.category) &&
!validPercentageSources.has(template.category.toLowerCase())
) {
return {
kind: 'percentage-source-not-found',
source: template.category,
};
}
return null;
case 'by': {
if (template.type !== 'by') return null;
if (!template.month || !monthUtils.isValidYearMonth(template.month)) {
return { kind: 'by-no-month' };
}
const targetMonth = template.month;
const startOfTodayMonth = monthUtils.monthFromDate(today);
// Pass bare YYYY-MM strings, matching the server-side check in
// CategoryTemplateContext.checkByAndScheduleAndSpend and avoiding the
// local-vs-UTC parsing footgun called out in shared/months.ts:_parse.
const monthsRemaining = monthUtils.differenceInCalendarMonths(
targetMonth,
startOfTodayMonth,
);
// Recurring goals (annual/repeat) anchored on a past month are
// legitimate — the engine rolls them forward by the period. Only flag
// the past-target case for one-shot goals. Mirrors the server check in
// CategoryTemplateContext.checkByAndScheduleAndSpend.
if (monthsRemaining < 0 && !template.annual && !template.repeat) {
return { kind: 'by-target-past', month: targetMonth };
}
return null;
}
default:
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

@@ -12,7 +12,6 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
type FieldLabelProps = {
@@ -47,71 +46,15 @@ const valueStyle = {
height: styles.mobileMinHeight,
};
const hideNativeDateIconClassName = css({
'&::-webkit-calendar-picker-indicator': {
display: 'none',
},
});
type InputFieldProps = ComponentPropsWithRef<typeof Input> & {
icon?: ReactNode;
};
type InputFieldProps = ComponentPropsWithRef<typeof Input>;
export function InputField({
disabled,
style,
onUpdate,
icon,
className,
ref,
...props
}: InputFieldProps) {
if (icon) {
return (
<View
style={{
...valueStyle,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 8,
gap: 8,
backgroundColor: disabled
? theme.formInputTextReadOnlySelection
: theme.tableBackground,
}}
>
<View style={{ color: theme.pageTextSubdued, flexShrink: 0 }}>
{icon}
</View>
<Input
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
style={{
flex: 1,
border: 'none',
backgroundColor: 'transparent',
height: '100%',
padding: 0,
color: disabled ? theme.tableTextInactive : theme.tableText,
...style,
}}
{...props}
className={renderProps =>
cx(
hideNativeDateIconClassName,
typeof className === 'function'
? className(renderProps)
: className,
)
}
/>
</View>
);
}
return (
<Input
ref={ref}
@@ -119,7 +62,6 @@ export function InputField({
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
className={className}
style={{
...valueStyle,
...style,
@@ -136,8 +78,6 @@ export function InputField({
InputField.displayName = 'InputField';
type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
icon?: ReactNode;
placeholder?: string;
rightContent?: ReactNode;
alwaysShowRightContent?: boolean;
textStyle?: CSSProperties;
@@ -165,15 +105,12 @@ export function TapField({
value,
children,
className,
icon,
placeholder,
rightContent,
alwaysShowRightContent,
textStyle,
ref,
...props
}: TapFieldProps) {
const showPlaceholder = !value && !!placeholder;
return (
<Button
ref={ref}
@@ -189,32 +126,16 @@ export function TapField({
{children ? (
children
) : (
<>
{icon && (
<View
style={{
color: theme.pageTextSubdued,
marginRight: 8,
flexShrink: 0,
}}
>
{icon}
</View>
)}
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
color: showPlaceholder
? theme.formInputTextPlaceholder
: undefined,
...textStyle,
}}
>
{showPlaceholder ? placeholder : value}
</Text>
</>
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
...textStyle,
}}
>
{value}
</Text>
)}
{(!props.isDisabled || alwaysShowRightContent) && rightContent}
</Button>

View File

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

View File

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

View File

@@ -160,7 +160,7 @@ const AmountInput = memo(function AmountInput({
}}
data-testid="amount-input-text"
>
{editing ? text || amountToCurrency(0) : amountToCurrency(value)}
{editing ? text : amountToCurrency(value)}
</Text>
</View>
);

View File

@@ -14,19 +14,11 @@ import { Button } from '@actual-app/components/button';
import { SvgSplit } from '@actual-app/components/icons/v0';
import {
SvgAdd,
SvgCalendar,
SvgCheveronDown,
SvgLocation,
SvgPiggyBank,
SvgTag,
SvgTrash,
SvgUser,
SvgWallet,
} from '@actual-app/components/icons/v1';
import {
SvgNotesPaper,
SvgPencilWriteAlternate,
} from '@actual-app/components/icons/v2';
import { SvgPencilWriteAlternate } from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
@@ -172,14 +164,6 @@ export function lookupName(items: CategoryEntity[], id?: CategoryEntity['id']) {
return items.find(item => item.id === id)?.name;
}
const dropdownChevron = (
<SvgCheveronDown
width={14}
height={14}
style={{ color: theme.pageTextSubdued, marginRight: 8 }}
/>
);
export function Status({
status,
isSplit = false,
@@ -445,9 +429,6 @@ const ChildTransactionEdit = forwardRef<
<View style={{ flexBasis: '75%' }}>
<FieldLabel title={t('Payee')} />
<TapField
icon={<SvgUser width={17} height={17} />}
placeholder={t('Who did you pay?')}
rightContent={dropdownChevron}
isDisabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'payee')
@@ -496,9 +477,6 @@ const ChildTransactionEdit = forwardRef<
<View>
<FieldLabel title={t('Category')} />
<TapField
icon={<SvgTag width={17} height={17} />}
placeholder={t('Select a category')}
rightContent={dropdownChevron}
textStyle={{
...((isOffBudget || isBudgetTransfer(transaction)) && {
fontStyle: 'italic',
@@ -521,8 +499,6 @@ const ChildTransactionEdit = forwardRef<
<View>
<FieldLabel title={t('Notes')} />
<InputField
icon={<SvgNotesPaper width={17} height={17} />}
placeholder={t('Add a note (optional)')}
disabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'notes')
@@ -1174,8 +1150,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Payee')} />
<TapField
icon={<SvgUser width={17} height={17} />}
placeholder={t('Who did you pay?')}
textStyle={{
...(transaction.is_parent && {
fontStyle: 'italic',
@@ -1237,9 +1211,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
style={{ marginLeft: 4 }}
/>
</Button>
) : (
dropdownChevron
)
) : undefined
}
/>
</View>
@@ -1248,9 +1220,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Category')} />
<TapField
icon={<SvgTag width={17} height={17} />}
placeholder={t('Select a category')}
rightContent={dropdownChevron}
style={{
...((isOffBudget || isBudgetTransfer(transaction)) && {
fontStyle: 'italic',
@@ -1331,9 +1300,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Account')} />
<TapField
icon={<SvgWallet width={17} height={17} />}
placeholder={t('Select an account')}
rightContent={dropdownChevron}
isDisabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'account')
@@ -1349,7 +1315,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<FieldLabel title={t('Date')} />
<InputField
type="date"
icon={<SvgCalendar width={17} height={17} />}
disabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'date')
@@ -1394,8 +1359,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Notes')} />
<InputField
icon={<SvgNotesPaper width={17} height={17} />}
placeholder={t('Add a note (optional)')}
disabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'notes')

View File

@@ -1,41 +1,21 @@
import type { Template } from '@actual-app/core/types/models/templates';
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
import { migrateTemplatesToAutomations } from './BudgetAutomationsModal';
describe('migrateTemplatesToAutomations', () => {
it('drops simple templates that have no limit and no monthly amount', () => {
// these would otherwise be pushed as a phantom 'fixed' entry that
// crashes FixedAutomationReadOnly (no .amount, no .period)
it('preserves simple templates that have no limit and no monthly amount', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
} satisfies Template;
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
});
const result = migrateTemplatesToAutomations([simpleTemplate]);
it('drops simple templates whose monthly amount is zero with no limit', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
monthly: 0,
} satisfies Template;
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
});
it('throws when a goal directive reaches migration', () => {
const goalTemplate = {
type: 'goal',
amount: 1000,
directive: 'goal',
} satisfies Template;
expect(() => migrateTemplatesToAutomations([goalTemplate])).toThrow(
/Unsupported template type/,
);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('week');
expect(result[0].template).toEqual(simpleTemplate);
expect(result[0].id).toMatch(/^automation-/);
});
it('expands a simple template with limit into limit and refill entries', () => {
@@ -83,7 +63,7 @@ describe('migrateTemplatesToAutomations', () => {
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('fixed');
expect(result[0].displayType).toBe('week');
expect(result[0].template).toMatchObject({
type: 'periodic',
amount: 45,
@@ -99,10 +79,7 @@ describe('migrateTemplatesToAutomations', () => {
});
});
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
// `#template 20 up to 200 per week` budgets 20/month and caps at the
// limit — the engine's runSimple returns just the monthly value, so
// there is no implicit refill-to-cap behaviour to migrate.
it('expands a simple template with both limit and monthly into three entries in order', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
@@ -117,9 +94,13 @@ describe('migrateTemplatesToAutomations', () => {
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(2);
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
expect(result[1].template).toMatchObject({
expect(result).toHaveLength(3);
expect(result.map(entry => entry.displayType)).toEqual([
'limit',
'refill',
'week',
]);
expect(result[2].template).toMatchObject({
type: 'periodic',
amount: 20,
directive: 'template',

View File

@@ -0,0 +1,465 @@
import { useCallback, useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import type { Template } from '@actual-app/core/types/models/templates';
import uniqueId from 'lodash/uniqueId';
import { Warning } from '#components/alerts';
import { BudgetAutomation } from '#components/budget/goals/BudgetAutomation';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import { DEFAULT_PRIORITY } from '#components/budget/goals/reducer';
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
import { useSchedules } from '#hooks/useSchedules';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
type AutomationEntry = {
id: string;
template: Template;
displayType: DisplayTemplateType;
};
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
switch (template.type) {
case 'percentage':
return 'percentage';
case 'schedule':
return 'schedule';
case 'periodic':
case 'simple':
return 'week';
case 'limit':
return 'limit';
case 'refill':
return 'refill';
case 'average':
case 'copy':
return 'historical';
default:
return 'week';
}
}
function createAutomationEntry(
template: Template,
displayType: DisplayTemplateType,
): AutomationEntry {
return {
id: uniqueId('automation-'),
template,
displayType,
};
}
export function migrateTemplatesToAutomations(
templates: Template[],
): AutomationEntry[] {
const entries: AutomationEntry[] = [];
templates.forEach(template => {
// Expand simple templates into limit, refill, and/or periodic templates
if (template.type === 'simple') {
let hasExpandedTemplate = false;
if (template.limit) {
hasExpandedTemplate = true;
entries.push(
createAutomationEntry(
{
type: 'limit',
amount: template.limit.amount,
hold: template.limit.hold,
period: template.limit.period,
start: template.limit.start,
directive: 'template',
priority: null,
},
'limit',
),
);
entries.push(
createAutomationEntry(
{
type: 'refill',
directive: 'template',
priority: template.priority,
},
'refill',
),
);
}
// If it has a monthly amount, create a periodic template
if (template.monthly != null && template.monthly !== 0) {
hasExpandedTemplate = true;
entries.push(
createAutomationEntry(
{
type: 'periodic',
amount: template.monthly,
period: {
period: 'month',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: template.priority,
},
'week',
),
);
}
if (!hasExpandedTemplate) {
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
}
return;
}
// For all other template types, create a single entry
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
});
return entries;
}
function BudgetAutomationList({
automations,
setAutomations,
schedules,
categories,
style,
}: {
automations: AutomationEntry[];
setAutomations: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
style?: CSSProperties;
}) {
const onAdd = () => {
setAutomations(prev => [
...prev,
createAutomationEntry(
{
type: 'periodic',
amount: 500,
period: {
period: 'month',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: DEFAULT_PRIORITY,
},
'week',
),
]);
};
const onAddLimit = () => {
setAutomations(prev => [
...prev,
createAutomationEntry(
{
directive: 'template',
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
'limit',
),
]);
};
const onDelete = (index: number) => () => {
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
};
const onSave = useCallback(
(index: number) =>
(template: Template, displayType: DisplayTemplateType) => {
setAutomations(prev =>
prev.map((oldAutomation, mapIndex) =>
mapIndex === index
? { ...oldAutomation, template, displayType }
: oldAutomation,
),
);
},
[setAutomations],
);
const hasLimitAutomation = automations.some(
automation => automation.displayType === 'limit',
);
return (
<SpaceBetween
direction="vertical"
gap={20}
align="stretch"
wrap={false}
style={{
overflowY: 'scroll',
...style,
}}
>
{automations.map((automation, index) => (
<BudgetAutomation
key={automation.id}
onSave={onSave(index)}
onDelete={onDelete(index)}
template={automation.template}
categories={categories}
schedules={schedules}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={
automation.displayType === 'refill' ? onAddLimit : undefined
}
readOnlyStyle={{
color: theme.pillText,
backgroundColor: theme.pillBackground,
borderRadius: 4,
padding: 16,
paddingLeft: 30,
paddingRight: 16,
}}
/>
))}
<Button onPress={onAdd}>
<Trans>Add new automation</Trans>
</Button>
</SpaceBetween>
);
}
function BudgetAutomationMigrationWarning({
categoryId,
style,
}: {
categoryId: string;
style?: CSSProperties;
}) {
const notes = useNotes(categoryId);
const templates = useMemo(() => {
if (!notes) return null;
const lines = notes.split('\n');
return lines
.flatMap(line => {
if (line.trim().startsWith('#template')) return line;
if (line.trim().startsWith('#goal')) return line;
if (line.trim().startsWith('#cleanup')) return line;
return [];
})
.join('\n');
}, [notes]);
if (!templates) return null;
return (
<Warning style={style}>
<SpaceBetween direction="vertical" style={{ minHeight: 'unset' }}>
<View>
<Trans>
This category uses notes-based automations (formerly "budget
templates"). We have automatically imported your existing
automations below. Please review them for accuracy and hit save to
complete the migration.
</Trans>
</View>
<View>
<Trans>
Original templates:
<View
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
marginTop: 4,
padding: 12,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
>
{templates}
</View>
</Trans>
</View>
</SpaceBetween>
</Warning>
);
}
export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
const { t } = useTranslation();
const dispatch = useDispatch();
const [automations, setAutomations] = useState<
Record<string, AutomationEntry[]>
>({});
const onLoaded = useCallback((result: Record<string, Template[]>) => {
const next: Record<string, AutomationEntry[]> = {};
for (const [id, templates] of Object.entries(result)) {
next[id] = migrateTemplatesToAutomations(templates);
}
setAutomations(next);
}, []);
const { loading } = useBudgetAutomations({
categoryId,
onLoaded,
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules } = useSchedules({
query: schedulesQuery,
});
const categories = useBudgetAutomationCategories();
const { data: currentCategory } = useCategory(categoryId);
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
const onSave = async (close: () => void) => {
if (!automations[categoryId]) {
close();
return;
}
const templates = automations[categoryId].map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{
id: categoryId,
templates,
},
],
source: 'ui',
});
close();
};
return (
<Modal
name="category-automations-edit"
containerProps={{
style: { width: 850, height: 650, paddingBottom: 20 },
}}
>
{({ state }) => (
<SpaceBetween
direction="vertical"
wrap={false}
align="stretch"
style={{ height: '100%' }}
>
<ModalHeader
title={t('Budget automations: {{category}}', {
category: currentCategory?.name,
})}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
{loading ? (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : (
<SpaceBetween align="stretch" direction="vertical" wrap={false}>
{needsMigration && (
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0 }}
/>
)}
<BudgetAutomationList
automations={automations[categoryId] || []}
setAutomations={(
cb: (prev: AutomationEntry[]) => AutomationEntry[],
) => {
setAutomations(prev => ({
...prev,
[categoryId]: cb(prev[categoryId] || []),
}));
}}
schedules={schedules}
categories={categories}
/>
</SpaceBetween>
)}
<View style={{ flexGrow: 1 }} />
<SpaceBetween
style={{
marginTop: 20,
justifyContent: 'flex-end',
flexShrink: 0,
}}
>
{!needsMigration && (
<Link
variant="text"
onClick={() => {
const templates = automations[categoryId] || [];
dispatch(
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: {
categoryId,
templates: templates.map(({ template }) => template),
},
},
}),
);
}}
>
<Trans>Un-migrate</Trans>
</Link>
)}
{/* <View style={{ flex: 1 }} /> */}
<Button onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={() => onSave(() => state.close())}
>
<Trans>Save</Trans>
</Button>
</SpaceBetween>
</SpaceBetween>
)}
</Modal>
);
}

View File

@@ -1,294 +0,0 @@
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgDelete } from '@actual-app/components/icons/v0';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Input } from '@actual-app/components/input';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import { ActiveEditor } from '#components/budget/goals/ActiveEditor';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import {
AutomationErrorDetail,
AutomationErrorTitle,
} from '#components/budget/goals/automationMessages';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import {
getInitialState,
templateReducer,
} from '#components/budget/goals/reducer';
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
import { TypePicker } from './TypePicker';
const CONFIG_PANEL_CLASS = css({
'& > *:first-child': {
marginTop: 0,
},
'& span > label': {
fontSize: 11,
fontWeight: 600,
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
textTransform: 'uppercase',
},
// Match Select borders to text inputs (Button uses buttonNormalBorder which
// is brighter than formInputBorder in dark/midnight themes).
'& button[type="button"]:not([aria-pressed])': {
borderColor: theme.formInputBorder,
},
});
const SINGLETON_TYPES: ReadonlySet<DisplayTemplateType> = new Set([
'limit',
'refill',
'remainder',
]);
type AutomationEditorPaneProps = {
entries: AutomationEntry[];
activeIdx: number;
automationErrors: (AutomationErrorKind | null)[];
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation: boolean;
onAddLimitAutomation: () => void;
setEntries: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
onDelete: (index: number) => void;
};
export function AutomationEditorPane({
entries,
activeIdx,
automationErrors,
schedules,
categories,
hasLimitAutomation,
onAddLimitAutomation,
setEntries,
onDelete,
}: AutomationEditorPaneProps) {
const active = entries[activeIdx];
const activeError = automationErrors[activeIdx];
const state = active ? getInitialState(active.template) : null;
const dispatch = (action: Parameters<typeof templateReducer>[1]) => {
setEntries(prev =>
prev.map((entry, i) => {
if (i !== activeIdx) return entry;
const current = getInitialState(entry.template);
const next = templateReducer(current, action);
return {
id: entry.id,
template: next.template,
displayType: next.displayType,
};
}),
);
};
const setPriority = (priority: number) => {
setEntries(prev =>
prev.map((entry, i) => {
if (i !== activeIdx) return entry;
const t = entry.template;
switch (t.type) {
case 'percentage':
case 'periodic':
case 'by':
case 'spend':
case 'simple':
case 'schedule':
case 'average':
case 'copy':
case 'refill':
return { ...entry, template: { ...t, priority } };
default:
return entry;
}
}),
);
};
const disabledTypes = new Set<DisplayTemplateType>();
entries.forEach((entry, i) => {
if (i !== activeIdx && SINGLETON_TYPES.has(entry.displayType)) {
disabledTypes.add(entry.displayType);
}
});
if (!active || !state) {
return (
<View style={{ padding: 20, color: theme.pageTextSubdued }}>
<Trans>Select an automation on the left.</Trans>
</View>
);
}
return (
<View
style={{
flex: 1,
padding: 20,
overflowY: 'auto',
gap: 14,
}}
>
{activeError && (
<View
style={{
padding: '10px 12px',
borderRadius: 6,
backgroundColor: theme.errorBackground,
border: `1px solid ${theme.errorBorder}`,
color: theme.errorText,
fontSize: 13,
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<SvgAlertTriangle
width={14}
height={14}
style={{ marginTop: 2, color: 'inherit', flexShrink: 0 }}
/>
<View style={{ minWidth: 0 }}>
<Text style={{ fontWeight: 600, color: 'inherit' }}>
<AutomationErrorTitle error={activeError} />
</Text>
<Text
style={{
fontSize: 12,
marginTop: 2,
color: 'inherit',
display: 'block',
}}
>
<AutomationErrorDetail error={activeError} />
</Text>
</View>
</View>
)}
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Trans>Automation type</Trans>
</Text>
<TypePicker
active={state.displayType}
disabledTypes={disabledTypes}
onPick={type => dispatch({ type: 'set-type', payload: type })}
/>
{state.displayType !== 'refill' && (
<>
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Trans>Configuration</Trans>
</Text>
<View
className={CONFIG_PANEL_CLASS}
style={{
padding: 16,
backgroundColor: theme.tableBackground,
borderRadius: 6,
border: `1px solid ${theme.tableBorder}`,
}}
>
<ActiveEditor
state={state}
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
</View>
</>
)}
{state.displayType === 'refill' && (
<ActiveEditor
state={state}
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
{'priority' in state.template &&
typeof state.template.priority === 'number' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Text
style={{
fontSize: 11,
fontWeight: 600,
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
<Trans>Priority</Trans>
</Text>
<Input
type="number"
style={{ width: 64 }}
value={String(state.template.priority)}
onChangeValue={value => {
if (value === '') return;
const parsed = Math.round(Number(value));
if (Number.isNaN(parsed)) return;
setPriority(Math.max(0, parsed));
}}
/>
</View>
)}
<View style={{ flex: 1 }} />
<Button
variant="bare"
onPress={() => onDelete(activeIdx)}
style={{ color: theme.errorText }}
>
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<SvgDelete width={10} height={10} style={{ color: 'inherit' }} />
<Trans>Delete automation</Trans>
</span>
</Button>
</View>
</View>
);
}

View File

@@ -1,179 +0,0 @@
import { useTranslation } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { AutomationErrorShort } from '#components/budget/goals/automationMessages';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
import { TemplateSentence } from '#components/budget/goals/TemplateSentence';
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
import { useFormat } from '#hooks/useFormat';
type AutomationListRowProps = {
index: number;
entry: AutomationEntry;
isActive: boolean;
error: AutomationErrorKind | null;
contribution: number | null;
categoryNameMap: Record<string, string>;
onSelect: (index: number) => void;
};
export function AutomationListRow({
index,
entry,
isActive,
error,
contribution,
categoryNameMap,
onSelect,
}: AutomationListRowProps) {
const { t } = useTranslation();
const format = useFormat();
const meta = getDisplayTemplateMeta(entry.displayType);
const Icon = meta.icon;
const subtitle = error ? (
<AutomationErrorShort error={error} />
) : (
<TemplateSentence
template={entry.template}
categoryNameMap={categoryNameMap}
/>
);
const borderColor = isActive
? theme.tableBorderSelected
: error
? theme.errorBorder
: 'transparent';
const backgroundColor = isActive
? theme.upcomingBackground
: error
? theme.errorBackground
: 'transparent';
const titleColor = error ? theme.errorText : theme.pageText;
const subtitleColor = error ? theme.errorText : theme.pageTextSubdued;
const priority =
'priority' in entry.template && typeof entry.template.priority === 'number'
? entry.template.priority
: null;
return (
<View
onClick={() => onSelect(index)}
aria-label={t('Select automation')}
style={{
flexShrink: 0,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 10,
marginBottom: 4,
borderRadius: 6,
border: `1px solid ${borderColor}`,
backgroundColor,
cursor: 'pointer',
position: 'relative',
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: error
? theme.errorBackground
: isActive
? theme.upcomingBackground
: theme.pillBackground,
color: error
? theme.errorText
: isActive
? theme.pageTextPositive
: theme.pageTextSubdued,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon width={14} height={14} style={{ color: 'inherit' }} />
</View>
<View style={{ minWidth: 0, flex: 1 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
fontSize: 12,
fontWeight: 600,
color: titleColor,
}}
>
<Text>{meta.label}</Text>
{error && (
<SvgAlertTriangle
width={11}
height={11}
style={{ color: 'inherit' }}
/>
)}
</View>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{subtitle}
</Text>
</View>
<View
style={{
flexShrink: 0,
alignItems: 'flex-end',
gap: 2,
}}
>
<Text
style={{
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color:
contribution == null ||
Number.isNaN(contribution) ||
contribution === 0
? theme.pageTextSubdued
: theme.pageText,
}}
>
{contribution == null || Number.isNaN(contribution)
? '—'
: contribution > 0
? '+' + format(contribution, 'financial')
: format(contribution, 'financial')}
</Text>
{priority != null && (
<Text
style={{
fontSize: 10,
color: theme.pageTextSubdued,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '0.04em',
}}
>
{t('Priority: {{priority}}', { priority })}
</Text>
)}
</View>
</View>
);
}

View File

@@ -1,68 +0,0 @@
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { Warning } from '#components/alerts';
import { useNotes } from '#hooks/useNotes';
export function BudgetAutomationMigrationWarning({
categoryId,
style,
}: {
categoryId: string;
style?: CSSProperties;
}) {
const notes = useNotes(categoryId);
if (!notes) return null;
const templates = notes
.split('\n')
.filter(line => /^\s*#(template|goal|cleanup)\b/.test(line))
.join('\n');
if (!templates) return null;
return (
<Warning
style={{
padding: '8px 12px',
fontSize: 12,
...style,
}}
>
<View style={{ gap: 4 }}>
<Text>
<Trans>
Imported from notes-based templates. Review and Save to complete the
migration.
</Trans>
</Text>
<details>
<summary style={{ cursor: 'pointer', fontSize: 11, opacity: 0.85 }}>
<Trans>Show original templates</Trans>
</summary>
<View
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 11,
marginTop: 6,
padding: 8,
borderRadius: 4,
// Translucent overlay rather than a theme token so the inset
// effect works regardless of the surrounding Warning colour
// (which differs between light/dark/midnight themes).
backgroundColor: 'rgba(0, 0, 0, 0.15)',
maxHeight: 120,
overflowY: 'auto',
}}
>
{templates}
</View>
</details>
</View>
</Warning>
);
}

View File

@@ -1,408 +0,0 @@
import { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
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 type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import debounce from 'lodash/debounce';
import {
createAutomationEntry,
getAutomationExamples,
} from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import {
validateAutomation,
validatePercentageAllocation,
} from '#components/budget/goals/validateAutomation';
import { Link } from '#components/common/Link';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
import { AutomationEditorPane } from './AutomationEditorPane';
import { AutomationListRow } from './AutomationListRow';
import { BudgetAutomationMigrationWarning } from './BudgetAutomationMigrationWarning';
import { ConflictBanner } from './ConflictBanner';
import { EmptyState } from './EmptyState';
const RULE_LIST_WIDTH = 310;
const ALWAYS_SCROLL_CLASS = css({
scrollbarGutter: 'stable',
'&::-webkit-scrollbar': {
width: 11,
backgroundColor: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
width: 7,
minHeight: 24,
borderRadius: 30,
backgroundClip: 'padding-box',
border: '2px solid rgba(0, 0, 0, 0)',
backgroundColor: theme.tableBorder,
},
});
type BudgetAutomationsBodyProps = {
categoryId: string;
categoryName: string;
needsMigration: boolean;
initialEntries: AutomationEntry[];
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
month: string;
onClose: () => void;
};
export function BudgetAutomationsBody({
categoryId,
categoryName,
needsMigration,
initialEntries,
schedules,
categories,
month,
onClose,
}: BudgetAutomationsBodyProps) {
const dispatch = useDispatch();
const format = useFormat();
const locale = useLocale();
const [entries, setEntries] = useState<AutomationEntry[]>(initialEntries);
const [activeIdx, setActiveIdx] = useState(0);
const [saving, setSaving] = useState(false);
const [dryRun, setDryRun] = useState<{
budgeted: number;
perTemplate: number[];
} | null>(null);
const onAddAutomation = (create?: () => AutomationEntry) => {
const fallback = getAutomationExamples().find(
e => e.displayType === 'fixed',
);
const entry = (create ?? fallback?.create)?.();
if (!entry) return;
setEntries(prev => {
const next = [...prev, entry];
setActiveIdx(next.length - 1);
return next;
});
};
const onAddLimitAutomation = () => {
const entry = createAutomationEntry(
{
directive: 'template',
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
'limit',
);
setEntries(prev => [entry, ...prev]);
setActiveIdx(0);
};
const onDelete = (index: number) => {
setEntries(prev => {
const next = prev.filter((_, i) => i !== index);
setActiveIdx(currentActive => {
if (next.length === 0) return 0;
if (currentActive >= next.length) return next.length - 1;
if (currentActive > index) return currentActive - 1;
return currentActive;
});
return next;
});
};
const onSave = async () => {
if (saving) return;
setSaving(true);
try {
const templatesToSave = entries.map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{ id: categoryId, templates: templatesToSave },
],
source: 'ui',
});
onClose();
} finally {
setSaving(false);
}
};
const onUnmigrate = () => {
dispatch(
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: {
categoryId,
templates: entries.map(({ template }) => template),
},
},
}),
);
};
const templates = entries.map(e => e.template);
const validPercentageSources = new Set<string>([
'all income',
'available funds',
]);
for (const group of categories) {
for (const cat of group.categories ?? []) {
if (!cat.is_income) continue;
validPercentageSources.add(cat.id);
if (cat.name) validPercentageSources.add(cat.name.toLowerCase());
}
}
const automationErrors = entries.map(entry =>
validateAutomation(
entry.template,
entry.displayType,
templates,
schedules,
new Date(),
validPercentageSources,
),
);
useEffect(() => {
if (templates.length === 0) {
setDryRun({ budgeted: 0, perTemplate: [] });
return;
}
let cancelled = false;
const run = debounce(async () => {
try {
const result = await send('budget/dry-run-category-template', {
month,
categoryId,
templates,
});
if (!cancelled) setDryRun(result);
} catch {
if (!cancelled) setDryRun(null);
}
}, 200);
void run();
return () => {
cancelled = true;
run.cancel();
};
}, [templates, month, categoryId]);
const totalMonthly = dryRun?.budgeted ?? 0;
const contributions: (number | null)[] = entries.map((_, i) =>
dryRun?.perTemplate?.[i] != null ? dryRun.perTemplate[i] : null,
);
const hasErrors = automationErrors.some(error => error !== null);
const conflict = validatePercentageAllocation(templates);
const categoryNameMap: Record<string, string> = {};
for (const group of categories) {
for (const cat of group.categories ?? []) {
categoryNameMap[cat.id] = cat.name;
}
}
const hasLimitAutomation = entries.some(e => e.displayType === 'limit');
const safeActiveIdx = Math.min(activeIdx, Math.max(0, entries.length - 1));
return (
<View style={{ flex: 1, flexDirection: 'column', minHeight: 0 }}>
<View
style={{
padding: '20px 24px 16px',
borderBottom: `1px solid ${theme.tableBorder}`,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
}}
>
<View style={{ minWidth: 0 }}>
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
<Trans>Budget automation</Trans>
</Text>
<Text
style={{
fontSize: 20,
fontWeight: 600,
color: theme.pageText,
marginTop: 2,
}}
>
{categoryName}
</Text>
</View>
<View style={{ textAlign: 'right', flexShrink: 0, minWidth: 220 }}>
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
}}
>
<Trans>
Projected for {{ month: formatMonthLabel(month, locale) }}
</Trans>
</Text>
<Text
style={{
fontSize: 22,
fontWeight: 600,
color: theme.pageTextPositive,
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.2,
display: 'block',
}}
>
{format(totalMonthly, 'financial')}
</Text>
</View>
</View>
{needsMigration && (
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0, margin: '12px 24px 0' }}
/>
)}
{conflict && <ConflictBanner conflict={conflict} />}
<View
style={{
flex: 1,
flexDirection: 'row',
minHeight: 0,
}}
>
<View
className={ALWAYS_SCROLL_CLASS}
style={{
width: RULE_LIST_WIDTH,
borderRight: `1px solid ${theme.tableBorder}`,
padding: 10,
overflowY: 'scroll',
}}
>
<View
style={{
flexShrink: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '6px 8px',
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Text>
<Trans>Automations</Trans>
</Text>
</View>
{entries.map((entry, i) => (
<AutomationListRow
key={entry.id}
index={i}
entry={entry}
isActive={i === safeActiveIdx}
error={automationErrors[i]}
contribution={contributions[i]}
categoryNameMap={categoryNameMap}
onSelect={setActiveIdx}
/>
))}
<Button
variant="bare"
onPress={() => onAddAutomation()}
style={{
width: '100%',
marginTop: 8,
padding: 10,
border: `1px dashed ${theme.tableBorder}`,
borderRadius: 6,
color: theme.pageTextPositive,
fontWeight: 600,
fontSize: 12,
justifyContent: 'center',
}}
>
+ <Trans>Add an automation</Trans>
</Button>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
{entries.length === 0 ? (
<EmptyState onAdd={onAddAutomation} />
) : (
<AutomationEditorPane
entries={entries}
activeIdx={safeActiveIdx}
automationErrors={automationErrors}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
setEntries={setEntries}
onDelete={onDelete}
/>
)}
</View>
</View>
<View
style={{
padding: '12px 20px',
borderTop: `1px solid ${theme.tableBorder}`,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
backgroundColor: theme.tableBackground,
flexShrink: 0,
}}
>
{!needsMigration && (
<Link variant="text" onClick={onUnmigrate}>
<Trans>Un-migrate to text notes</Trans>
</Link>
)}
<View style={{ flex: 1 }} />
<Button onPress={onClose}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={onSave}
isDisabled={hasErrors || conflict !== null || saving}
>
<Trans>Save</Trans>
</Button>
</View>
</View>
);
}

View File

@@ -1,136 +0,0 @@
import { useState } from 'react';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { View } from '@actual-app/components/view';
import { currentMonth } from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import type { Template } from '@actual-app/core/types/models/templates';
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
import { Modal } from '#components/common/Modal';
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
import { useSchedules } from '#hooks/useSchedules';
import { BudgetAutomationsBody } from './BudgetAutomationsBody';
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
import {
hasCleanupLine,
UnsupportedDirectivesNotice,
} from './UnsupportedDirectivesNotice';
const MODAL_WIDTH = 960;
const MODAL_HEIGHT = 760;
export function BudgetAutomationsModal({
categoryId,
month,
}: {
categoryId: string;
month?: string;
}) {
const [parsedTemplates, setParsedTemplates] = useState<Template[] | null>(
null,
);
const effectiveMonth = month ?? currentMonth();
const onLoaded = (result: Record<string, Template[]>) => {
setParsedTemplates(result[categoryId] ?? []);
};
const { loading } = useBudgetAutomations({ categoryId, onLoaded });
const { schedules } = useSchedules({ query: q('schedules').select('*') });
const categories = useBudgetAutomationCategories();
const { data: currentCategory } = useCategory(categoryId);
const notes = useNotes(categoryId);
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
const hasGoalTemplate =
parsedTemplates?.some(t => t.type === 'goal') ?? false;
const hasErrorTemplate =
parsedTemplates?.some(t => t.type === 'error') ?? false;
const hasSpendTemplate =
parsedTemplates?.some(t => t.type === 'spend') ?? false;
// Only surface stale `#cleanup` lines for categories that haven't been
// migrated to UI-managed automations; once `source === 'ui'`, the notes
// are no longer the source of truth.
const hasCleanupDirective = needsMigration && hasCleanupLine(notes);
const hasUnsupportedDirective =
hasGoalTemplate ||
hasErrorTemplate ||
hasSpendTemplate ||
hasCleanupDirective;
const incomeNameToId = new Map<string, string>();
for (const group of categories) {
for (const cat of group.categories ?? []) {
if (cat.name) incomeNameToId.set(cat.name.toLowerCase(), cat.id);
}
}
const resolved = parsedTemplates?.map(t => {
if (t.type !== 'percentage' || !t.category) return t;
const id = incomeNameToId.get(t.category.toLowerCase());
return id ? { ...t, category: id } : t;
});
const initialEntries =
resolved && !hasUnsupportedDirective
? migrateTemplatesToAutomations(resolved)
: null;
return (
<Modal
name="category-automations-edit"
containerProps={{
style: {
width: MODAL_WIDTH,
maxWidth: '95vw',
height: MODAL_HEIGHT,
maxHeight: '90vh',
padding: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
},
}}
>
{({ state }) => (
<View style={{ flex: 1, minHeight: 0 }}>
{loading || parsedTemplates === null ? (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : hasUnsupportedDirective ? (
<UnsupportedDirectivesNotice
hasGoalTemplate={hasGoalTemplate}
hasErrorTemplate={hasErrorTemplate}
hasSpendTemplate={hasSpendTemplate}
hasCleanupDirective={hasCleanupDirective}
onClose={() => state.close()}
/>
) : (
<BudgetAutomationsBody
categoryId={categoryId}
categoryName={currentCategory?.name ?? ''}
needsMigration={needsMigration}
initialEntries={initialEntries ?? []}
schedules={schedules}
categories={categories}
month={effectiveMonth}
onClose={() => state.close()}
/>
)}
</View>
)}
</Modal>
);
}

View File

@@ -1,39 +0,0 @@
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
GlobalConflictDetail,
GlobalConflictTitle,
} from '#components/budget/goals/automationMessages';
import type { GlobalConflictKind } from '#components/budget/goals/validateAutomation';
type ConflictBannerProps = {
conflict: GlobalConflictKind;
};
export function ConflictBanner({ conflict }: ConflictBannerProps) {
return (
<View
style={{
padding: '8px 22px',
backgroundColor: theme.errorBackground,
borderBottom: `1px solid ${theme.errorBorder}`,
color: theme.errorText,
fontSize: 12,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
}}
>
<SvgAlertTriangle width={14} height={14} style={{ color: 'inherit' }} />
<Text style={{ color: 'inherit' }}>
<strong>
<GlobalConflictTitle conflict={conflict} />.
</strong>{' '}
<GlobalConflictDetail conflict={conflict} />
</Text>
</View>
);
}

View File

@@ -1,138 +0,0 @@
import { Trans } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { getAutomationExamples } from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
type EmptyStateProps = {
onAdd: (create: () => AutomationEntry) => void;
};
export function EmptyState({ onAdd }: EmptyStateProps) {
const examples = getAutomationExamples();
return (
<View
style={{
padding: '40px 20px',
textAlign: 'center',
maxWidth: 540,
margin: '0 auto',
}}
>
<View
style={{
width: 56,
height: 56,
borderRadius: 12,
margin: '0 auto 14px',
backgroundColor: theme.upcomingBackground,
color: theme.pageTextPositive,
alignItems: 'center',
justifyContent: 'center',
}}
>
<SvgAlertTriangle width={20} height={20} style={{ color: 'inherit' }} />
</View>
<Text
style={{
fontSize: 18,
fontWeight: 600,
color: theme.pageText,
letterSpacing: '-0.01em',
}}
>
<Trans>No automations yet</Trans>
</Text>
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
marginTop: 4,
marginBottom: 22,
display: 'block',
}}
>
<Trans>
Budget automations keep this category funded with one click each
month. Start with one of these.
</Trans>
</Text>
<View
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: 10,
textAlign: 'center',
}}
>
{examples.map(example => {
const meta = getDisplayTemplateMeta(example.displayType);
const Icon = meta.icon;
return (
<View
key={example.displayType}
role="button"
tabIndex={0}
aria-label={meta.label}
onClick={() => onAdd(example.create)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onAdd(example.create);
}
}}
style={{
padding: 14,
borderRadius: 8,
backgroundColor: theme.cardBackground,
border: `1px solid ${theme.tableBorder}`,
gap: 6,
cursor: 'pointer',
}}
>
<View
style={{
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: theme.upcomingBackground,
color: theme.pageTextPositive,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: 6,
}}
>
<Icon width={16} height={16} />
</View>
<Text
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageText,
}}
>
{meta.label}
</Text>
<Text
style={{
fontSize: 11,
color: theme.pageTextSubdued,
lineHeight: 1.4,
}}
>
{meta.description}
</Text>
</View>
);
})}
</View>
</View>
);
}

View File

@@ -1,109 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { displayTemplateTypes } from '#components/budget/goals/constants';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
type TypePickerProps = {
active: DisplayTemplateType;
disabledTypes: ReadonlySet<DisplayTemplateType>;
onPick: (type: DisplayTemplateType) => void;
};
export function TypePicker({ active, disabledTypes, onPick }: TypePickerProps) {
const { t } = useTranslation();
const entries = displayTemplateTypes.map(
id => [id, getDisplayTemplateMeta(id)] as const,
);
const disabledHint = t('Only one of this type allowed per category');
return (
<View
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 8,
}}
>
{entries.map(([id, meta]) => {
const Icon = meta.icon;
const isActive = id === active;
const isDisabled = !isActive && disabledTypes.has(id);
return (
<View
key={id}
role="button"
tabIndex={isDisabled ? -1 : 0}
aria-pressed={isActive}
aria-disabled={isDisabled}
title={isDisabled ? disabledHint : undefined}
onClick={() => {
if (!isDisabled) onPick(id);
}}
onKeyDown={e => {
if (isDisabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPick(id);
}
}}
style={{
padding: '10px 10px 8px',
borderRadius: 6,
backgroundColor: isActive
? theme.upcomingBackground
: theme.cardBackground,
border: `1px solid ${isActive ? theme.pageTextPositive : theme.tableBorder}`,
gap: 6,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.45 : 1,
minWidth: 0,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Icon
width={16}
height={16}
style={{
flexShrink: 0,
color: isActive ? theme.pageTextPositive : theme.pageText,
}}
/>
<Text
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
color: isActive ? theme.pageTextPositive : theme.pageText,
lineHeight: 1.25,
}}
>
{meta.label}
</Text>
</View>
<Text
style={{
display: 'block',
fontSize: 11,
color: theme.pageTextSubdued,
lineHeight: 1.35,
}}
>
{meta.description}
</Text>
</View>
);
})}
</View>
);
}

View File

@@ -1,100 +0,0 @@
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
export function UnsupportedDirectivesNotice({
hasGoalTemplate,
hasErrorTemplate,
hasSpendTemplate,
hasCleanupDirective,
onClose,
}: {
hasGoalTemplate: boolean;
hasErrorTemplate: boolean;
hasSpendTemplate: boolean;
hasCleanupDirective: boolean;
onClose: () => void;
}) {
return (
<View
style={{
flex: 1,
padding: 32,
gap: 16,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}}
>
<SvgAlertTriangle
width={32}
height={32}
style={{ color: theme.errorText }}
/>
<Text
style={{
fontSize: 18,
fontWeight: 600,
color: theme.pageText,
}}
>
<Trans>This category isn&rsquo;t supported in the UI yet</Trans>
</Text>
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
maxWidth: 480,
lineHeight: 1.5,
}}
>
{hasErrorTemplate ? (
<Trans>
One or more <code>#template</code> lines in this category&rsquo;s
notes couldn&rsquo;t be parsed. Fix them as text first, then re-open
this modal to migrate.
</Trans>
) : hasSpendTemplate ? (
<Trans>
This category uses a <code>spend from</code> template, which the
budget automations UI doesn&rsquo;t handle yet. Keep editing it as
text in the category&rsquo;s notes.
</Trans>
) : hasGoalTemplate && hasCleanupDirective ? (
<Trans>
This category&rsquo;s notes use <code>#goal</code> and{' '}
<code>#cleanup</code> directives, neither of which the budget
automations UI handles yet. Keep editing them as text in the
category&rsquo;s notes.
</Trans>
) : hasGoalTemplate ? (
<Trans>
This category uses a <code>#goal</code> directive, which the budget
automations UI doesn&rsquo;t handle yet. Keep editing it as text in
the category&rsquo;s notes.
</Trans>
) : (
<Trans>
This category uses a <code>#cleanup</code> directive, which the
budget automations UI doesn&rsquo;t handle yet. Keep editing it as
text in the category&rsquo;s notes.
</Trans>
)}
</Text>
<Button onPress={onClose}>
<Trans>Close</Trans>
</Button>
</View>
);
}
const CLEANUP_DIRECTIVE = /^\s*#cleanup\b/;
export function hasCleanupLine(notes: string | null | undefined): boolean {
if (!notes) return false;
return notes.split('\n').some(line => CLEANUP_DIRECTIVE.test(line));
}

View File

@@ -1,2 +0,0 @@
export { BudgetAutomationsModal } from './BudgetAutomationsModal';
export { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';

View File

@@ -1,111 +0,0 @@
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import { createAutomationEntry } from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
switch (template.type) {
case 'percentage':
return 'percentage';
case 'schedule':
return 'schedule';
case 'periodic':
case 'simple':
return 'fixed';
case 'limit':
return 'limit';
case 'refill':
return 'refill';
case 'average':
case 'copy':
return 'historical';
case 'by':
return 'by';
case 'remainder':
return 'remainder';
case 'goal':
case 'error':
case 'spend':
// filtered upstream by hasUnsupportedDirective; surface if it ever isn't
throw new Error(`Unsupported template type reached migration`);
default: {
const _exhaustive: never = template;
void _exhaustive;
throw new Error(`Unhandled template type`);
}
}
}
export function migrateTemplatesToAutomations(
templates: Template[],
): AutomationEntry[] {
const entries: AutomationEntry[] = [];
templates.forEach(template => {
if (template.type === 'simple') {
const monthly = template.monthly;
const hasMonthly = monthly != null && monthly !== 0;
if (template.limit) {
entries.push(
createAutomationEntry(
{
type: 'limit',
amount: template.limit.amount,
hold: template.limit.hold,
period: template.limit.period,
start: template.limit.start,
directive: 'template',
priority: null,
},
'limit',
),
);
// The implicit refill only applies to a limit-only simple template
// (e.g. `#template up to 200`). When a monthly amount is also set
// (`#template 50 up to 200`), the engine just budgets the monthly
// amount and clamps to the cap — no top-up to the limit.
if (!hasMonthly) {
entries.push(
createAutomationEntry(
{
type: 'refill',
directive: 'template',
priority: template.priority,
},
'refill',
),
);
}
}
if (hasMonthly) {
entries.push(
createAutomationEntry(
{
type: 'periodic',
amount: monthly,
period: { period: 'month', amount: 1 },
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: template.priority,
},
'fixed',
),
);
}
// a simple template with neither monthly nor limit is a no-op; drop it
// rather than passing through as a phantom 'fixed' entry that would
// crash FixedAutomationReadOnly (no .amount, no .period)
return;
}
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
});
return entries;
}

View File

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

View File

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

View File

@@ -11,26 +11,9 @@ import type { Template } from '@actual-app/core/types/models/templates';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { Notes } from '#components/Notes';
import { useCategories } from '#hooks/useCategories';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
// The UI's CategoryAutocomplete stores the income category id on a
// percentage template, but text-template grammar addresses categories by
// name. Rewrite percentage templates so the un-migrated notes are readable
// (and don't drift if the category is later renamed).
function sanitizePercentageCategoriesForNotes(
templates: Template[],
idToName: Map<string, string>,
): Template[] {
return templates.map(template => {
if (template.type !== 'percentage') return template;
const name = idToName.get(template.category);
if (name) return { ...template, category: name };
return template;
});
}
export function UnmigrateBudgetAutomationsModal({
categoryId,
templates,
@@ -40,7 +23,6 @@ export function UnmigrateBudgetAutomationsModal({
}) {
const { t } = useTranslation();
const { data: category } = useCategory(categoryId);
const { data: categoryData } = useCategories();
const existingNotes = useNotes(categoryId) || '';
const [editedNotes, setEditedNotes] = useState<string>('');
@@ -48,18 +30,12 @@ export function UnmigrateBudgetAutomationsModal({
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!categoryData?.list) return;
const idToName = new Map<string, string>();
for (const cat of categoryData.list) {
idToName.set(cat.id, cat.name);
}
const sanitized = sanitizePercentageCategoriesForNotes(templates, idToName);
let mounted = true;
void (async () => {
try {
const text: string = await send(
'budget/render-note-templates',
sanitized,
templates,
);
if (mounted) setRendered(text);
} catch {
@@ -69,7 +45,7 @@ export function UnmigrateBudgetAutomationsModal({
return () => {
mounted = false;
};
}, [templates, categoryData]);
}, [templates]);
// Seed editable notes once templates rendered
useEffect(() => {
@@ -111,21 +87,13 @@ export function UnmigrateBudgetAutomationsModal({
async function onSave(close: () => void) {
setSaving(true);
try {
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
// Hand control back to the notes parser: clear the UI-managed goal_def
// and mark notes as the source of truth. `storeNoteTemplates` will
// re-derive goal_def from the notes the next time it runs (e.g. on
// modal open or when applying templates).
await send('budget/set-category-automations', {
categoriesWithTemplates: [{ id: categoryId, templates: [] }],
source: 'notes',
});
await send('budget/store-note-templates');
close();
} finally {
setSaving(false);
}
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
await send('budget/set-category-automations', {
categoriesWithTemplates: [{ id: categoryId, templates }],
source: 'notes',
});
setSaving(false);
close();
}
return (

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest';
import { calculateSpendingReportTimeRange } from './reportRanges';
// In test mode, monthUtils.currentMonth() returns '2017-01'
describe('calculateSpendingReportTimeRange', () => {
it('preserves the saved compare month for live average reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
isLive: true,
mode: 'average',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-12');
});
it('preserves the saved compare month for live budget reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
isLive: true,
mode: 'budget',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-12');
});
it('preserves the saved compare months for live single month reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
compareTo: '2016-11',
isLive: true,
mode: 'single-month',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-11');
});
it('defaults live average reports to the current month without a saved compare month', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
isLive: true,
mode: 'average',
});
expect(compare).toBe('2017-01');
expect(compareTo).toBe('2017-01');
});
});

View File

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

View File

@@ -415,16 +415,12 @@ function DateSelectDesktop({
inputProps?.onBlur?.(e);
if (clearOnBlur) {
// If value is empty, reset to previously selected value
// instead of saving an empty date (which the server rejects).
// If value is empty, that drives what gets selected.
// Otherwise the input is reset to whatever is already
// selected
if (value === '') {
if (selectedValue) {
setValue(selectedValue);
const date = parse(selectedValue, dateFormat, new Date());
if (date instanceof Date && !isNaN(date.valueOf())) {
onSelect(format(date, 'yyyy-MM-dd'));
}
}
setSelectedValue('');
onSelect('');
} else {
setValue(selectedValue || '');

View File

@@ -151,7 +151,10 @@ export function ExperimentalFeatures() {
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const showGoalTemplatesUI = goalTemplatesEnabled || goalTemplatesUIEnabled;
const showGoalTemplatesUI =
goalTemplatesUIEnabled ||
(goalTemplatesEnabled &&
localStorage.getItem('devEnableGoalTemplatesUI') === 'true');
const showServerPrefs =
localStorage.getItem('devEnableServerPrefs') === 'true';
@@ -166,10 +169,7 @@ export function ExperimentalFeatures() {
</FeatureToggle>
{showGoalTemplatesUI && (
<View style={{ paddingLeft: 22 }}>
<FeatureToggle
flag="goalTemplatesUIEnabled"
feedbackLink="https://github.com/actualbudget/actual/issues/7692"
>
<FeatureToggle flag="goalTemplatesUIEnabled">
<Trans>Subfeature: Budget automations UI</Trans>
</FeatureToggle>
</View>

View File

@@ -9,16 +9,11 @@ import {
generateThemeId,
validateThemeCss,
} from '#style/customThemes';
import type { CatalogTheme } from '#style/customThemes';
import { ThemeInstaller } from './ThemeInstaller';
function render(
ui: Parameters<typeof rtlRender>[0],
options?: Parameters<typeof rtlRender>[1],
) {
return rtlRender(ui, { wrapper: TestProviders, ...options });
}
const render: typeof rtlRender = (ui, options) =>
rtlRender(ui, { wrapper: TestProviders, ...options });
vi.mock('#style/customThemes', async () => {
const actual = await vi.importActual('#style/customThemes');
@@ -64,11 +59,10 @@ describe('ThemeInstaller', () => {
--color-secondary: #6c757d;
}`;
const mockCatalog: CatalogTheme[] = [
const mockCatalog = [
{
name: 'Demo Theme',
repo: 'actualbudget/demo-theme',
mode: 'dark',
colors: [
'#1a1a2e',
'#16213e',
@@ -81,7 +75,6 @@ describe('ThemeInstaller', () => {
{
name: 'Ocean Blue',
repo: 'actualbudget/ocean-theme',
mode: 'light',
colors: [
'#0d47a1',
'#1565c0',
@@ -94,7 +87,6 @@ describe('ThemeInstaller', () => {
{
name: 'Forest Green',
repo: 'actualbudget/forest-theme',
mode: 'light',
colors: [
'#1b5e20',
'#2e7d32',

View File

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

Some files were not shown because too many files have changed in this diff Show More