Compare commits

..

27 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
4bea2bd070 Update nightly versioning script to use yarn 2026-03-17 16:26:19 +00:00
Matiss Janis Aboltins
45a7c029ba Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-17 16:14:51 +00:00
Matiss Janis Aboltins
c4ee71409e [AI] Add Yarn constraints to enforce consistent dependency versions (#7229)
* [AI] Add yarn constraints to enforce consistent dependency versions

Adds a `yarn.config.cjs` that uses Yarn 4's built-in constraints feature
to detect when the same dependency is declared with different version
ranges across workspaces. Workspace protocol references and
peerDependencies are excluded from the check.

Also adds a `yarn constraints` convenience script and the `@yarnpkg/types`
dev dependency for type-checked constraint authoring.

https://claude.ai/code/session_01B1xRjZXn6b18anZjo8cbqb

* Add release notes for PR #7229

* Add constraints job to GitHub Actions workflow

* Fix constraints

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 16:00:34 +00:00
Matt Fiddaman
dfd6e468a6 ⬆️ react-spring 10.0.3 (#7224)
* react-spring (10.0.0 -> ^10.0.3)

* note

* fix VRT

* fix more animations

* fix budget month colouring
2026-03-17 16:00:27 +00:00
Matiss Janis Aboltins
5b227f5fa1 feat: Add post-checkout hook to run yarn install if yarn.lock changes (#7230) 2026-03-17 15:58:02 +00:00
Julian Dominguez-Schatz
e1606b31ab Migrate get-next-package-version.js to TypeScript (#7227)
* Migrate `get-next-package-version.js` to TypeScript

* Add release notes

* Stronger type check

* Fix step ordering

* Fix typo

* Fix missed ordering
2026-03-17 13:56:47 +00:00
Matiss Janis Aboltins
1c21476ad3 Enhance configuration tests to include 'encryptionPassword' checks for CLI options and environment variables, ensuring proper priority handling in the configuration resolution process. 2026-03-17 12:28:21 +00:00
Matiss Janis Aboltins
b03a507d33 Enhance configuration validation by adding support for 'ACTUAL_ENCRYPTION_PASSWORD' and implementing a new validation function for config file content. Update documentation to clarify error output format for the CLI tool. 2026-03-17 12:04:31 +00:00
Matiss Janis Aboltins
b8a255abc8 Refactor configuration to replace "budgetId" with "syncId" across CLI and documentation 2026-03-17 11:17:20 +00:00
Michael Clark
4f7c3c51a5 🐛 Using a shared worker to coordinate multiple tabs (#7172)
* attempt to enable sync when multiple tabs are open

* allow multiple tabs to work

* release notes

* rehome the host if the tab closes

* ensure new tabs always receive failure  messages by broadcasting them on interval

* reject after retries are exhausted

* forwarding the logs from the worker to the main browser

* [autofix.ci] apply automated fixes

* add preflight fetch from main thread to server endpoint to trigger permission prompt if required

* remove the log prefix for cleaner logs

* adding heardbeat to detect closed tabs so they can be removed from the list

* store failure payload and broadcast for new tabs after timeout is cleared

* if a tab closes a budget, force other tabs to go to the budget list screen

* fix safari by detecting crossoriginisolated as a dependency for shared worker

* all ios to fallback to non-shared-worker implemenation

* coordinator and all backend work going through a leader tab to enable ios

* electing new leader tab when oone tab closes or is refreshed

* logic for standalone tabs to rejoin shared workers when on same budget

* remove the preflight request, shouldnt be needed now the code runs on the main process

* handling brand new tabs going to open budgets that are current standalone with no leader

* allowing budgets to be closed  without kickother others by transfering leadership to remaining oopened tabs

* remove unnedd comments

* change approach slightly - no more standalone, now every budget gets leader promotion automatically)

* adding tests and fixed minor bug to do with deleting budget with multiple tabs open

* fix worker not loading

* trouble with ts - moving to js

* reintroduce ts for the worker

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-17 09:30:34 +00:00
Matt Fiddaman
0e1fc07bf3 ⬆️ @types/react (#7223)
* bump @types/react

* note
2026-03-17 08:17:48 +00:00
Matiss Janis Aboltins
53cdc6fa48 [AI] Further hardening of "/change-password" endpoint (#7207)
* [AI] Fix OIDC privilege escalation in /change-password endpoint

Add admin role check and password auth_method session check to prevent
non-admin or OIDC-authenticated users from changing the server password.
Previously, any authenticated user could overwrite the password hash and
then login via password method to obtain an ADMIN session.

https://claude.ai/code/session_01Wne9FY2QnKp6JF7g61B1Sn

* Add release notes for PR #7207

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 08:16:46 +00:00
okxint
1d0281025d fix: preserve schedule link when merging transactions (#7177)
* [AI] fix: preserve schedule link when merging transactions

When merging two transactions where one is linked to a schedule,
the schedule field was not included in the merge update, causing
the schedule association to be silently dropped. This resulted in
duplicate transactions and incorrect "Due" status for scheduled
transactions.

Add `schedule: keep.schedule || drop.schedule` to both the normal
merge path and the subtransaction merge path, matching the existing
fallback pattern used for payee, category, notes, etc.

Add three test cases covering:
- Schedule preserved from dropped transaction when kept has none
- Kept transaction's schedule takes priority when both have one
- Schedule preserved when merging manual scheduled with banksynced

Fixes #6997

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add release notes for PR #7177

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:14:16 +00:00
Matiss Janis Aboltins
231bd96092 Update documentation to replace "CLI tool" with "Server CLI" for consistency across multiple files. This change clarifies the distinction between the command-line interface for the Actual Budget application and the sync-server CLI tool. 2026-03-16 21:37:19 +00:00
Matiss Janis Aboltins
a3eb298f09 Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-16 21:33:14 +00:00
Matiss Janis Aboltins
f73c5e9210 [AI] Fix adm-zip dependency resolution in loot-core (#7219) 2026-03-16 21:11:51 +00:00
Matiss Janis Aboltins
722ff6eb85 Enhance size comparison workflow to include CLI build checks and artifact downloads
- Added steps to wait for CLI build success in both base and PR workflows.
- Included downloading of CLI build artifacts for comparison between base and PR branches.
- Updated failure reporting to account for CLI build status.
2026-03-16 19:01:15 +00:00
Matiss Janis Aboltins
6f1bac2977 Update CLI dependencies and build workflow
- Upgrade Vite to version 8.0.0 and Vitest to version 4.1.0 in package.json.
- Add rollup-plugin-visualizer for bundle analysis.
- Modify build workflow to prepare and upload CLI bundle stats.
- Update size comparison workflow to include CLI stats.
- Remove obsolete vitest.config.ts file as its configuration is now integrated into vite.config.ts.
2026-03-16 18:39:06 +00:00
Matiss Janis Aboltins
c64b7ce754 Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-16 18:35:11 +00:00
Matiss Janis Aboltins
0d7e3d2007 Update CLI TypeScript configuration to include Vitest globals and streamline test imports across multiple test files for improved clarity and consistency. 2026-03-15 21:52:09 +00:00
Matiss Janis Aboltins
3ddb403bfe Enhance CLI functionality: Update configuration loading to support additional search places for config files. Refactor error handling in command options to improve validation and user feedback. Introduce new utility functions for parsing boolean flags and update related commands to utilize these functions. Add comprehensive tests for new utility functions to ensure reliability. 2026-03-15 20:54:27 +00:00
Matiss Janis Aboltins
843274e00e Update package.json exports configuration to support environment-specific module resolution. Added 'development' and 'default' entries for improved clarity in file usage. 2026-03-15 18:52:39 +00:00
Matiss Janis Aboltins
fd916a925d Enhance package.json: Add exports configuration for module resolution and publish settings. This includes specifying types and default files for better compatibility and clarity in package usage. 2026-03-15 18:39:35 +00:00
Matiss Janis Aboltins
8e2a996671 Refactor tests: streamline imports in connection and accounts test files for improved clarity and consistency. Remove dynamic imports in favor of static imports. 2026-03-15 18:27:26 +00:00
Matiss Janis Aboltins
70b802da39 Refactor CLI options: replace --quiet with --verbose for improved message control. Update related configurations and tests to reflect this change. Adjust build command in workflow for consistency. 2026-03-15 18:20:49 +00:00
Matiss Janis Aboltins
b16e5d685c Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-15 18:11:30 +00:00
Claude
c4de834f98 [AI] Add @actual-app/cli package
New CLI tool wrapping the full @actual-app/api surface for interacting with
Actual Budget from the command line. Connects to a sync server and supports
all CRUD operations across accounts, budgets, categories, transactions,
payees, tags, rules, schedules, and AQL queries.
2026-03-15 18:09:43 +00:00
127 changed files with 5944 additions and 1259 deletions

View File

@@ -2,6 +2,7 @@ Abanca
ABNAMRO
ABNANL
Activo
actualrc
AESUDEF
ALZEY
Anglais
@@ -110,8 +111,8 @@ KBCBE
Keycloak
Khurozov
KORT
KRW
Kreditbank
KRW
lage
LHV
LHVBEE

View File

@@ -81,6 +81,31 @@ jobs:
name: build-stats
path: packages/desktop-client/build-stats
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build CLI
run: yarn build:cli
- name: Create package tgz
run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz
- name: Prepare bundle stats artifact
run: cp packages/cli/dist/stats.json cli-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-cli
path: packages/cli/actual-cli.tgz
- name: Upload CLI bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: cli-build-stats
path: cli-stats.json
server:
runs-on: ubuntu-latest
steps:

View File

@@ -12,6 +12,16 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
constraints:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Check dependency version consistency
run: yarn constraints
lint:
runs-on: ubuntu-latest
steps:

View File

@@ -42,6 +42,8 @@ jobs:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -56,11 +58,9 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron

View File

@@ -20,6 +20,10 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
@@ -35,12 +39,12 @@ jobs:
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "${{ github.event.inputs.version }}" \
--update)
else
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)

View File

@@ -39,6 +39,9 @@ jobs:
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -53,16 +56,14 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update

View File

@@ -20,16 +20,18 @@ jobs:
- name: Update package versions
run: |
# Get new nightly versions
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
# Set package versions
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
- name: Yarn install
run: |
@@ -54,6 +56,13 @@ jobs:
run: |
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Build CLI
run: yarn workspace @actual-app/cli build
- name: Pack the cli package
run: |
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
@@ -63,6 +72,7 @@ jobs:
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
packages/cli/@actual-app/cli.tgz
publish:
runs-on: ubuntu-latest
@@ -106,3 +116,9 @@ jobs:
npm publish api/@actual-app/api.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: |
npm publish cli/@actual-app/cli.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -35,6 +35,13 @@ jobs:
run: |
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Build CLI
run: yarn workspace @actual-app/cli build
- name: Pack the cli package
run: |
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
@@ -44,6 +51,7 @@ jobs:
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
packages/cli/@actual-app/cli.tgz
publish:
runs-on: ubuntu-latest
@@ -87,3 +95,9 @@ jobs:
npm publish api/@actual-app/api.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: |
npm publish cli/@actual-app/cli.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -57,6 +57,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CLI build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
@@ -72,9 +79,16 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CLI PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.event.pull_request.head.sha}}
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
@@ -115,6 +129,23 @@ jobs:
name: api-build-stats
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then
@@ -136,9 +167,11 @@ jobs:
--base desktop-client=./base/web-stats.json \
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--base cli=./base/cli-stats.json \
--head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \
--head cli=./head/cli-stats.json \
--identifier combined \
--format pr-body > bundle-stats-comment.md
- name: Post combined bundle stats comment

7
.gitignore vendored
View File

@@ -81,3 +81,10 @@ build/
*storybook.log
storybook-static
# cli config when testing locally
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

13
.husky/post-checkout Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# Run yarn install when switching branches (if yarn.lock changed)
# $3 is 1 for branch checkout, 0 for file checkout
if [ "$3" != "1" ]; then
exit 0
fi
# Check if yarn.lock changed between the old and new HEAD
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -17,6 +17,7 @@ module.exports = {
},
build: {
type: 'npmScript',
dependsOn: ['^build'],
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],

View File

@@ -34,12 +34,14 @@
"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",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"build:cli": "yarn build --scope=@actual-app/cli",
"build:docs": "yarn workspace docs build",
"build:storybook": "yarn workspace @actual-app/components build:storybook",
"deploy:docs": "yarn workspace docs deploy",
@@ -57,6 +59,7 @@
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
@@ -66,6 +69,7 @@
"@types/node": "^22.19.10",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@yarnpkg/types": "^4.0.1",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
@@ -88,6 +92,7 @@
"typescript": "^5.9.3"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},

View File

@@ -1,4 +1,7 @@
class Query {
/** @type {import('loot-core/shared/query').QueryState} */
state;
constructor(state) {
this.state = {
filterExpressions: state.filterExpressions || [],

View File

@@ -9,6 +9,20 @@
],
"main": "dist/index.js",
"types": "@types/index.d.ts",
"exports": {
".": {
"development": "./index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./@types/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest --run",

View File

@@ -2,13 +2,13 @@
// This script is used in GitHub Actions to get the next version based on the current package.json version.
// It supports three types of versioning: nightly, hotfix, and monthly.
import fs from 'node:fs';
import { parseArgs } from 'node:util';
import { getNextVersion } from '../src/versions/get-next-package-version.js';
const args = process.argv;
import {
getNextVersion,
isValidVersionType,
} from '../src/versions/get-next-package-version';
const options = {
'package-json': {
@@ -28,40 +28,53 @@ const options = {
short: 'u',
default: false,
},
};
} as const;
function fail(message: string): never {
console.error(message);
process.exit(1);
}
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
if (!values['package-json']) {
console.error(
const packageJsonPath = values['package-json'];
if (!packageJsonPath) {
fail(
'Please specify the path to package.json using --package-json or -p option.',
);
process.exit(1);
}
try {
const packageJsonPath = values['package-json'];
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
fail('The specified package.json does not contain a valid version field.');
}
const currentVersion = packageJson.version;
const explicitVersion = values.version;
let newVersion;
if (explicitVersion) {
newVersion = explicitVersion;
} else {
const type = values.type;
if (!type || !isValidVersionType(type)) {
fail('Please specify the release type using --type or -t.');
}
try {
newVersion = getNextVersion({
currentVersion,
type: values.type,
type,
currentDate: new Date(),
});
} catch (e) {
console.error(e.message);
process.exit(1);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
@@ -76,6 +89,5 @@ try {
);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
}

8
packages/ci-actions/bin/tsx Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
cd ../../
script="$1"
shift
exec node --import=extensionless/register --experimental-strip-types packages/ci-actions/"$script" "$@"

View File

@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"tsx": "node --import=extensionless/register --experimental-strip-types",
"tsx": "bin/tsx",
"test": "vitest --run",
"typecheck": "tsgo -b"
},

View File

@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
expect(() =>
getNextVersion({
currentVersion: '25.8.4',
type: 'unknown',
type: 'unknown' as never,
currentDate: new Date('2025-08-10'),
}),
).toThrow(/Invalid type/);

View File

@@ -1,35 +1,69 @@
function parseVersion(version) {
export const versionTypeArray = [
'auto',
'hotfix',
'monthly',
'nightly',
] as const;
export type VersionType = (typeof versionTypeArray)[number];
type ParsedVersion = {
versionYear: number;
versionMonth: number;
versionHotfix: number;
};
type GetNextVersionOptions = {
currentVersion: string;
type: VersionType;
currentDate?: Date;
};
function parseVersion(version: string): ParsedVersion {
const [y, m, p] = version.split('.');
return {
versionYear: parseInt(y, 10),
versionMonth: parseInt(m, 10),
versionHotfix: parseInt(p, 10),
versionYear: Number.parseInt(y, 10),
versionMonth: Number.parseInt(m, 10),
versionHotfix: Number.parseInt(p, 10),
};
}
function computeNextMonth(versionYear, versionMonth) {
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
function computeNextMonth(versionYear: number, versionMonth: number) {
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const fullYear = nextVersionMonthDate.getFullYear();
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
return { nextVersionYear, nextVersionMonth };
}
// Determine logical type from 'auto' based on the current date and version
function resolveType(type, currentDate, versionYear, versionMonth) {
if (type !== 'auto') return type;
export function isValidVersionType(value: string): value is VersionType {
return versionTypeArray.includes(value as VersionType);
}
function resolveType(
type: VersionType,
currentDate: Date,
versionYear: number,
versionMonth: number,
) {
if (type !== 'auto') {
return type;
}
const inPatchMonth =
currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
if (inPatchMonth && currentDate.getDate() <= 25) {
return 'hotfix';
}
return 'monthly';
}
@@ -37,7 +71,7 @@ export function getNextVersion({
currentVersion,
type,
currentDate = new Date(),
}) {
}: GetNextVersionOptions) {
const { versionYear, versionMonth, versionHotfix } =
parseVersion(currentVersion);
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
@@ -51,11 +85,10 @@ export function getNextVersion({
versionMonth,
);
// Format date stamp once for nightly
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replaceAll('-', '');
.replace(/-/g, '');
switch (resolvedType) {
case 'nightly':
@@ -66,7 +99,7 @@ export function getNextVersion({
return `${nextVersionYear}.${nextVersionMonth}.0`;
default:
throw new Error(
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
);
}
}

View File

@@ -2,8 +2,8 @@
"compilerOptions": {
"target": "ES2022",
"lib": [],
"module": "nodenext",
"moduleResolution": "nodenext",
"module": "es2022",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"types": ["node"],

7
packages/cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
dist
coverage
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

155
packages/cli/README.md Normal file
View File

@@ -0,0 +1,155 @@
# @actual-app/cli
> **WARNING:** This CLI is experimental.
Command-line interface for [Actual Budget](https://actualbudget.org). Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more.
> **Note:** This CLI connects to a running [Actual sync server](https://actualbudget.org/docs/install/). It does not operate on local budget files directly.
## Installation
```bash
npm install -g @actual-app/cli
```
Requires Node.js >= 22.
## Quick Start
```bash
# Set connection details
export ACTUAL_SERVER_URL=http://localhost:5006
export ACTUAL_PASSWORD=your-password
export ACTUAL_SYNC_ID=your-sync-id # Found in Settings → Advanced → Sync ID
# List your accounts
actual accounts list
# Check a balance
actual accounts balance <account-id>
# View this month's budget
actual budgets month 2026-03
```
## Configuration
Configuration is resolved in this order (highest priority first):
1. **CLI flags** (`--server-url`, `--password`, etc.)
2. **Environment variables**
3. **Config file** (via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig))
4. **Defaults** (`dataDir` defaults to `~/.actual-cli/data`)
### Environment Variables
| Variable | Description |
| ---------------------- | --------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
### Config File
Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`):
```json
{
"serverUrl": "http://localhost:5006",
"password": "your-password",
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
}
```
**Security:** Do not store plaintext passwords in config files (e.g. `.actualrc.json`, `.actualrc`, `.actualrc.yaml`, `actual.config.js`). Add these files to `.gitignore` if they contain secrets. Prefer the `ACTUAL_SESSION_TOKEN` environment variable instead of the `password` field. See [Environment Variables](#environment-variables) for using a session token.
### Global Flags
| Flag | Description |
| ------------------------- | ----------------------------------------------- |
| `--server-url <url>` | Server URL |
| `--password <pw>` | Server password |
| `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Data directory |
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
| `--verbose` | Show informational messages |
## Commands
| Command | Description |
| ----------------- | ------------------------------ |
| `accounts` | Manage accounts |
| `budgets` | Manage budgets and allocations |
| `categories` | Manage categories |
| `category-groups` | Manage category groups |
| `transactions` | Manage transactions |
| `payees` | Manage payees |
| `tags` | Manage tags |
| `rules` | Manage transaction rules |
| `schedules` | Manage scheduled transactions |
| `query` | Run an ActualQL query |
| `server` | Server utilities and lookups |
Run `actual <command> --help` for subcommands and options.
### Examples
```bash
# List all accounts (as a table)
actual accounts list --format table
# Find an entity ID by name
actual server get-id --type accounts --name "Checking"
# Add a transaction (amount in integer cents: -2500 = -$25.00)
actual transactions add --account <id> \
--data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
# Export transactions to CSV
actual transactions list --account <id> \
--start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
# Set budget amount ($500 = 50000 cents)
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
# Run an ActualQL query
actual query run --table transactions \
--select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
```
### Amount Convention
All monetary amounts are **integer cents**:
| CLI Value | Dollar Amount |
| --------- | ------------- |
| `5000` | $50.00 |
| `-12350` | -$123.50 |
## Running Locally (Development)
If you're working on the CLI within the monorepo:
```bash
# 1. Build the CLI
yarn build:cli
# 2. Start a local sync server (in a separate terminal)
yarn start:server-dev
# 3. Open http://localhost:5006 in your browser, create a budget,
# then find the Sync ID in Settings → Advanced → Sync ID
# 4. Run the CLI directly from the build output
ACTUAL_SERVER_URL=http://localhost:5006 \
ACTUAL_PASSWORD=your-password \
ACTUAL_SYNC_ID=your-sync-id \
node packages/cli/dist/cli.js accounts list
# Or use a shorthand alias for convenience
alias actual-dev="node $(pwd)/packages/cli/dist/cli.js"
actual-dev budgets list
```

35
packages/cli/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@actual-app/cli",
"version": "26.3.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"bin": {
"actual": "./dist/cli.js",
"actual-cli": "./dist/cli.js"
},
"files": [
"dist"
],
"type": "module",
"scripts": {
"build": "vite build",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"dependencies": {
"@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5",
"commander": "^13.0.0",
"cosmiconfig": "^9.0.0"
},
"devDependencies": {
"@types/node": "^22.19.10",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.5",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -0,0 +1,259 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { registerAccountsCommand } from './accounts';
vi.mock('@actual-app/api', () => ({
getAccounts: vi.fn().mockResolvedValue([]),
createAccount: vi.fn().mockResolvedValue('new-id'),
updateAccount: vi.fn().mockResolvedValue(undefined),
closeAccount: vi.fn().mockResolvedValue(undefined),
reopenAccount: vi.fn().mockResolvedValue(undefined),
deleteAccount: vi.fn().mockResolvedValue(undefined),
getAccountBalance: vi.fn().mockResolvedValue(10000),
}));
vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerAccountsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('accounts commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('list', () => {
it('calls api.getAccounts and prints result', async () => {
const accounts = [{ id: '1', name: 'Checking' }];
vi.mocked(api.getAccounts).mockResolvedValue(accounts);
await run(['accounts', 'list']);
expect(api.getAccounts).toHaveBeenCalled();
expect(printOutput).toHaveBeenCalledWith(accounts, undefined);
});
it('passes format option to printOutput', async () => {
vi.mocked(api.getAccounts).mockResolvedValue([]);
await run(['--format', 'csv', 'accounts', 'list']);
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
});
describe('create', () => {
it('passes name and defaults to api.createAccount', async () => {
await run(['accounts', 'create', '--name', 'Savings']);
expect(api.createAccount).toHaveBeenCalledWith(
{ name: 'Savings', offbudget: false },
0,
);
expect(printOutput).toHaveBeenCalledWith({ id: 'new-id' }, undefined);
});
it('passes offbudget and balance options', async () => {
await run([
'accounts',
'create',
'--name',
'Investments',
'--offbudget',
'--balance',
'50000',
]);
expect(api.createAccount).toHaveBeenCalledWith(
{ name: 'Investments', offbudget: true },
50000,
);
});
});
describe('update', () => {
it('passes fields to api.updateAccount', async () => {
await run(['accounts', 'update', 'acct-1', '--name', 'NewName']);
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
name: 'NewName',
});
expect(printOutput).toHaveBeenCalledWith(
{ success: true, id: 'acct-1' },
undefined,
);
});
it('passes offbudget true', async () => {
await run([
'accounts',
'update',
'acct-1',
'--name',
'X',
'--offbudget',
'true',
]);
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
name: 'X',
offbudget: true,
});
});
it('passes offbudget false', async () => {
await run([
'accounts',
'update',
'acct-1',
'--name',
'X',
'--offbudget',
'false',
]);
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
name: 'X',
offbudget: false,
});
});
it('rejects invalid offbudget value', async () => {
await expect(
run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']),
).rejects.toThrow(
'Invalid --offbudget: "yes". Expected "true" or "false".',
);
});
it('rejects empty name', async () => {
await expect(
run(['accounts', 'update', 'acct-1', '--name', ' ']),
).rejects.toThrow('Invalid --name: must be a non-empty string.');
});
it('rejects update with no fields', async () => {
await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow(
'No update fields provided. Use --name or --offbudget.',
);
});
});
describe('close', () => {
it('passes transfer options to api.closeAccount', async () => {
await run([
'accounts',
'close',
'acct-1',
'--transfer-account',
'acct-2',
]);
expect(api.closeAccount).toHaveBeenCalledWith(
'acct-1',
'acct-2',
undefined,
);
});
it('passes transfer category', async () => {
await run([
'accounts',
'close',
'acct-1',
'--transfer-category',
'cat-1',
]);
expect(api.closeAccount).toHaveBeenCalledWith(
'acct-1',
undefined,
'cat-1',
);
});
});
describe('reopen', () => {
it('calls api.reopenAccount', async () => {
await run(['accounts', 'reopen', 'acct-1']);
expect(api.reopenAccount).toHaveBeenCalledWith('acct-1');
expect(printOutput).toHaveBeenCalledWith(
{ success: true, id: 'acct-1' },
undefined,
);
});
});
describe('delete', () => {
it('calls api.deleteAccount', async () => {
await run(['accounts', 'delete', 'acct-1']);
expect(api.deleteAccount).toHaveBeenCalledWith('acct-1');
expect(printOutput).toHaveBeenCalledWith(
{ success: true, id: 'acct-1' },
undefined,
);
});
});
describe('balance', () => {
it('calls api.getAccountBalance without cutoff', async () => {
await run(['accounts', 'balance', 'acct-1']);
expect(api.getAccountBalance).toHaveBeenCalledWith('acct-1', undefined);
expect(printOutput).toHaveBeenCalledWith(
{ id: 'acct-1', balance: 10000 },
undefined,
);
});
it('calls api.getAccountBalance with cutoff date', async () => {
await run(['accounts', 'balance', 'acct-1', '--cutoff', '2025-01-15']);
expect(api.getAccountBalance).toHaveBeenCalledWith(
'acct-1',
new Date('2025-01-15'),
);
});
});
});

View File

@@ -0,0 +1,135 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag, parseIntFlag } from '../utils';
export function registerAccountsCommand(program: Command) {
const accounts = program.command('accounts').description('Manage accounts');
accounts
.command('list')
.description('List all accounts')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getAccounts();
printOutput(result, opts.format);
});
});
accounts
.command('create')
.description('Create a new account')
.requiredOption('--name <name>', 'Account name')
.option('--offbudget', 'Create as off-budget account', false)
.option('--balance <amount>', 'Initial balance in cents', '0')
.action(async cmdOpts => {
const balance = parseIntFlag(cmdOpts.balance, '--balance');
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createAccount(
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
balance,
);
printOutput({ id }, opts.format);
});
});
accounts
.command('update <id>')
.description('Update an account')
.option('--name <name>', 'New account name')
.option('--offbudget <bool>', 'Set off-budget status')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) {
const trimmed = cmdOpts.name.trim();
if (trimmed === '') {
throw new Error('Invalid --name: must be a non-empty string.');
}
fields.name = trimmed;
}
if (cmdOpts.offbudget !== undefined) {
fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget');
}
if (Object.keys(fields).length === 0) {
throw new Error(
'No update fields provided. Use --name or --offbudget.',
);
}
await withConnection(opts, async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('close <id>')
.description('Close an account')
.option(
'--transfer-account <id>',
'Transfer remaining balance to this account',
)
.option(
'--transfer-category <id>',
'Transfer remaining balance to this category',
)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.closeAccount(
id,
cmdOpts.transferAccount,
cmdOpts.transferCategory,
);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('reopen <id>')
.description('Reopen a closed account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.reopenAccount(id);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('delete <id>')
.description('Delete an account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteAccount(id);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('balance <id>')
.description('Get account balance')
.option('--cutoff <date>', 'Cutoff date (YYYY-MM-DD)')
.action(async (id: string, cmdOpts) => {
let cutoff: Date | undefined;
if (cmdOpts.cutoff) {
const cutoffDate = new Date(cmdOpts.cutoff);
if (Number.isNaN(cutoffDate.getTime())) {
throw new Error(
'Invalid cutoff date: expected a valid date (e.g. YYYY-MM-DD).',
);
}
cutoff = cutoffDate;
}
const opts = program.opts();
await withConnection(opts, async () => {
const balance = await api.getAccountBalance(id, cutoff);
printOutput({ id, balance }, opts.format);
});
});
}

View File

@@ -0,0 +1,135 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { resolveConfig } from '../config';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag, parseIntFlag } from '../utils';
export function registerBudgetsCommand(program: Command) {
const budgets = program.command('budgets').description('Manage budgets');
budgets
.command('list')
.description('List all available budgets')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getBudgets();
printOutput(result, opts.format);
},
{ loadBudget: false },
);
});
budgets
.command('download <syncId>')
.description('Download a budget by sync ID')
.option('--encryption-password <password>', 'Encryption password')
.action(async (syncId: string, cmdOpts) => {
const opts = program.opts();
const config = await resolveConfig(opts);
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
await withConnection(
opts,
async () => {
await api.downloadBudget(syncId, {
password,
});
printOutput({ success: true, syncId }, opts.format);
},
{ loadBudget: false },
);
});
budgets
.command('sync')
.description('Sync the current budget')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.sync();
printOutput({ success: true }, opts.format);
});
});
budgets
.command('months')
.description('List available budget months')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonths();
printOutput(result, opts.format);
});
});
budgets
.command('month <month>')
.description('Get budget data for a specific month (YYYY-MM)')
.action(async (month: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonth(month);
printOutput(result, opts.format);
});
});
budgets
.command('set-amount')
.description('Set budget amount for a category in a month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID')
.requiredOption('--amount <amount>', 'Amount in cents')
.action(async cmdOpts => {
const amount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('set-carryover')
.description('Enable/disable carryover for a category')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID')
.requiredOption('--flag <bool>', 'Enable (true) or disable (false)')
.action(async cmdOpts => {
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('hold-next-month')
.description('Hold budget amount for next month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--amount <amount>', 'Amount in cents')
.action(async cmdOpts => {
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('reset-hold')
.description('Reset budget hold for a month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.resetBudgetHold(cmdOpts.month);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -0,0 +1,75 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag } from '../utils';
export function registerCategoriesCommand(program: Command) {
const categories = program
.command('categories')
.description('Manage categories');
categories
.command('list')
.description('List all categories')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCategories();
printOutput(result, opts.format);
});
});
categories
.command('create')
.description('Create a new category')
.requiredOption('--name <name>', 'Category name')
.requiredOption('--group-id <id>', 'Category group ID')
.option('--is-income', 'Mark as income category', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createCategory({
name: cmdOpts.name,
group_id: cmdOpts.groupId,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
});
categories
.command('update <id>')
.description('Update a category')
.option('--name <name>', 'New category name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.hidden !== undefined) {
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
}
if (Object.keys(fields).length === 0) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategory(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
categories
.command('delete <id>')
.description('Delete a category')
.option('--transfer-to <id>', 'Transfer transactions to this category')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategory(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -0,0 +1,73 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag } from '../utils';
export function registerCategoryGroupsCommand(program: Command) {
const groups = program
.command('category-groups')
.description('Manage category groups');
groups
.command('list')
.description('List all category groups')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
});
});
groups
.command('create')
.description('Create a new category group')
.requiredOption('--name <name>', 'Group name')
.option('--is-income', 'Mark as income group', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createCategoryGroup({
name: cmdOpts.name,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
});
groups
.command('update <id>')
.description('Update a category group')
.option('--name <name>', 'New group name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.hidden !== undefined) {
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
}
if (Object.keys(fields).length === 0) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategoryGroup(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
groups
.command('delete <id>')
.description('Delete a category group')
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -0,0 +1,95 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
export function registerPayeesCommand(program: Command) {
const payees = program.command('payees').description('Manage payees');
payees
.command('list')
.description('List all payees')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getPayees();
printOutput(result, opts.format);
});
});
payees
.command('common')
.description('List frequently used payees')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCommonPayees();
printOutput(result, opts.format);
});
});
payees
.command('create')
.description('Create a new payee')
.requiredOption('--name <name>', 'Payee name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createPayee({ name: cmdOpts.name });
printOutput({ id }, opts.format);
});
});
payees
.command('update <id>')
.description('Update a payee')
.option('--name <name>', 'New payee name')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name) fields.name = cmdOpts.name;
if (Object.keys(fields).length === 0) {
throw new Error(
'No fields to update. Use --name to specify a new name.',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
payees
.command('delete <id>')
.description('Delete a payee')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deletePayee(id);
printOutput({ success: true, id }, opts.format);
});
});
payees
.command('merge')
.description('Merge payees into a target payee')
.requiredOption('--target <id>', 'Target payee ID')
.requiredOption('--ids <ids>', 'Comma-separated payee IDs to merge')
.action(async (cmdOpts: { target: string; ids: string }) => {
const mergeIds = cmdOpts.ids
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0);
if (mergeIds.length === 0) {
throw new Error(
'No valid payee IDs provided in --ids. Provide comma-separated IDs.',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -0,0 +1,93 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
import { parseIntFlag } from '../utils';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function buildQueryFromFile(
parsed: Record<string, unknown>,
fallbackTable: string | undefined,
) {
const table = typeof parsed.table === 'string' ? parsed.table : fallbackTable;
if (!table) {
throw new Error(
'--table is required when the input file lacks a "table" field',
);
}
let queryObj = api.q(table);
if (Array.isArray(parsed.select)) queryObj = queryObj.select(parsed.select);
if (isRecord(parsed.filter)) queryObj = queryObj.filter(parsed.filter);
if (Array.isArray(parsed.orderBy)) {
queryObj = queryObj.orderBy(parsed.orderBy);
}
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
return queryObj;
}
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
if (!cmdOpts.table) {
throw new Error('--table is required (or use --file)');
}
let queryObj = api.q(cmdOpts.table);
if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(','));
}
if (cmdOpts.filter) {
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
}
if (cmdOpts.orderBy) {
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
}
if (cmdOpts.limit) {
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
}
return queryObj;
}
export function registerQueryCommand(program: Command) {
const query = program
.command('query')
.description('Run AQL (Actual Query Language) queries');
query
.command('run')
.description('Execute an AQL query')
.option(
'--table <table>',
'Table to query (transactions, accounts, categories, payees)',
)
.option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter expression as JSON')
.option('--order-by <fields>', 'Comma-separated fields to order by')
.option('--limit <n>', 'Limit number of results')
.option(
'--file <path>',
'Read full query object from JSON file (use - for stdin)',
)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
if (parsed !== undefined && !isRecord(parsed)) {
throw new Error('Query file must contain a JSON object');
}
const queryObj = parsed
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
printOutput(result, opts.format);
});
});
}

View File

@@ -0,0 +1,77 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
export function registerRulesCommand(program: Command) {
const rules = program
.command('rules')
.description('Manage transaction rules');
rules
.command('list')
.description('List all rules')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getRules();
printOutput(result, opts.format);
});
});
rules
.command('payee-rules <payeeId>')
.description('List rules for a specific payee')
.action(async (payeeId: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getPayeeRules(payeeId);
printOutput(result, opts.format);
});
});
rules
.command('create')
.description('Create a new rule')
.option('--data <json>', 'Rule definition as JSON')
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.createRule
>[0];
const id = await api.createRule(rule);
printOutput({ id }, opts.format);
});
});
rules
.command('update')
.description('Update a rule')
.option('--data <json>', 'Rule data as JSON (must include id)')
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.updateRule
>[0];
await api.updateRule(rule);
printOutput({ success: true }, opts.format);
});
});
rules
.command('delete <id>')
.description('Delete a rule')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteRule(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -0,0 +1,67 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
export function registerSchedulesCommand(program: Command) {
const schedules = program
.command('schedules')
.description('Manage scheduled transactions');
schedules
.command('list')
.description('List all schedules')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getSchedules();
printOutput(result, opts.format);
});
});
schedules
.command('create')
.description('Create a new schedule')
.option('--data <json>', 'Schedule definition as JSON')
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const schedule = readJsonInput(cmdOpts) as Parameters<
typeof api.createSchedule
>[0];
const id = await api.createSchedule(schedule);
printOutput({ id }, opts.format);
});
});
schedules
.command('update <id>')
.description('Update a schedule')
.option('--data <json>', 'Fields to update as JSON')
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
.option('--reset-next-date', 'Reset next occurrence date', false)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateSchedule
>[1];
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
printOutput({ success: true, id }, opts.format);
});
});
schedules
.command('delete <id>')
.description('Delete a schedule')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteSchedule(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -0,0 +1,60 @@
import * as api from '@actual-app/api';
import { Option } from 'commander';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
export function registerServerCommand(program: Command) {
const server = program.command('server').description('Server utilities');
server
.command('version')
.description('Get server version')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const version = await api.getServerVersion();
printOutput({ version }, opts.format);
},
{ loadBudget: false },
);
});
server
.command('get-id')
.description('Get entity ID by name')
.addOption(
new Option('--type <type>', 'Entity type')
.choices(['accounts', 'categories', 'payees', 'schedules'])
.makeOptionMandatory(),
)
.requiredOption('--name <name>', 'Entity name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
printOutput(
{ id, type: cmdOpts.type, name: cmdOpts.name },
opts.format,
);
});
});
server
.command('bank-sync')
.description('Run bank synchronization')
.option('--account <id>', 'Specific account ID to sync')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const args = cmdOpts.account
? { accountId: cmdOpts.account }
: undefined;
await api.runBankSync(args);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -0,0 +1,74 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
export function registerTagsCommand(program: Command) {
const tags = program.command('tags').description('Manage tags');
tags
.command('list')
.description('List all tags')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getTags();
printOutput(result, opts.format);
});
});
tags
.command('create')
.description('Create a new tag')
.requiredOption('--tag <tag>', 'Tag name')
.option('--color <color>', 'Tag color')
.option('--description <description>', 'Tag description')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createTag({
tag: cmdOpts.tag,
color: cmdOpts.color,
description: cmdOpts.description,
});
printOutput({ id }, opts.format);
});
});
tags
.command('update <id>')
.description('Update a tag')
.option('--tag <tag>', 'New tag name')
.option('--color <color>', 'New tag color')
.option('--description <description>', 'New tag description')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag;
if (cmdOpts.color !== undefined) fields.color = cmdOpts.color;
if (cmdOpts.description !== undefined) {
fields.description = cmdOpts.description;
}
if (Object.keys(fields).length === 0) {
throw new Error(
'At least one of --tag, --color, or --description is required',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateTag(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
tags
.command('delete <id>')
.description('Delete a tag')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteTag(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -0,0 +1,114 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
export function registerTransactionsCommand(program: Command) {
const transactions = program
.command('transactions')
.description('Manage transactions');
transactions
.command('list')
.description('List transactions for an account')
.requiredOption('--account <id>', 'Account ID')
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getTransactions(
cmdOpts.account,
cmdOpts.start,
cmdOpts.end,
);
printOutput(result, opts.format);
});
});
transactions
.command('add')
.description('Add transactions to an account')
.requiredOption('--account <id>', 'Account ID')
.option('--data <json>', 'Transaction data as JSON array')
.option(
'--file <path>',
'Read transaction data from JSON file (use - for stdin)',
)
.option('--learn-categories', 'Learn category assignments', false)
.option('--run-transfers', 'Process transfers', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.addTransactions
>[1];
const result = await api.addTransactions(
cmdOpts.account,
transactions,
{
learnCategories: cmdOpts.learnCategories,
runTransfers: cmdOpts.runTransfers,
},
);
printOutput(result, opts.format);
});
});
transactions
.command('import')
.description('Import transactions to an account')
.requiredOption('--account <id>', 'Account ID')
.option('--data <json>', 'Transaction data as JSON array')
.option(
'--file <path>',
'Read transaction data from JSON file (use - for stdin)',
)
.option('--dry-run', 'Preview without importing', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.importTransactions
>[1];
const result = await api.importTransactions(
cmdOpts.account,
transactions,
{
defaultCleared: true,
dryRun: cmdOpts.dryRun,
},
);
printOutput(result, opts.format);
});
});
transactions
.command('update <id>')
.description('Update a transaction')
.option('--data <json>', 'Fields to update as JSON')
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateTransaction
>[1];
await api.updateTransaction(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
transactions
.command('delete <id>')
.description('Delete a transaction')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteTransaction(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -0,0 +1,185 @@
import { homedir } from 'os';
import { join } from 'path';
import { resolveConfig } from './config';
const mockSearch = vi.fn().mockResolvedValue(null);
vi.mock('cosmiconfig', () => ({
cosmiconfig: () => ({
search: (...args: unknown[]) => mockSearch(...args),
}),
}));
function mockConfigFile(config: Record<string, unknown> | null) {
if (config) {
mockSearch.mockResolvedValue({ config, isEmpty: false });
} else {
mockSearch.mockResolvedValue(null);
}
}
describe('resolveConfig', () => {
const savedEnv: Record<string, string | undefined> = {};
const envKeys = [
'ACTUAL_SERVER_URL',
'ACTUAL_PASSWORD',
'ACTUAL_SESSION_TOKEN',
'ACTUAL_SYNC_ID',
'ACTUAL_DATA_DIR',
'ACTUAL_ENCRYPTION_PASSWORD',
];
beforeEach(() => {
for (const key of envKeys) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
mockConfigFile(null);
});
afterEach(() => {
for (const key of envKeys) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key];
} else {
delete process.env[key];
}
}
});
describe('priority chain', () => {
it('CLI opts take highest priority', async () => {
process.env.ACTUAL_SERVER_URL = 'http://env';
process.env.ACTUAL_PASSWORD = 'envpw';
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
mockConfigFile({
serverUrl: 'http://file',
password: 'filepw',
encryptionPassword: 'file-enc',
});
const config = await resolveConfig({
serverUrl: 'http://cli',
password: 'clipw',
encryptionPassword: 'cli-enc',
});
expect(config.serverUrl).toBe('http://cli');
expect(config.password).toBe('clipw');
expect(config.encryptionPassword).toBe('cli-enc');
});
it('env vars override file config', async () => {
process.env.ACTUAL_SERVER_URL = 'http://env';
process.env.ACTUAL_PASSWORD = 'envpw';
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
mockConfigFile({
serverUrl: 'http://file',
password: 'filepw',
encryptionPassword: 'file-enc',
});
const config = await resolveConfig({});
expect(config.serverUrl).toBe('http://env');
expect(config.password).toBe('envpw');
expect(config.encryptionPassword).toBe('env-enc');
});
it('file config is used when no CLI opts or env vars', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'filepw',
syncId: 'budget-1',
encryptionPassword: 'file-enc',
});
const config = await resolveConfig({});
expect(config.serverUrl).toBe('http://file');
expect(config.password).toBe('filepw');
expect(config.syncId).toBe('budget-1');
expect(config.encryptionPassword).toBe('file-enc');
});
});
describe('defaults', () => {
it('dataDir defaults to ~/.actual-cli/data', async () => {
const config = await resolveConfig({
serverUrl: 'http://test',
password: 'pw',
});
expect(config.dataDir).toBe(join(homedir(), '.actual-cli', 'data'));
});
it('CLI opt overrides default dataDir', async () => {
const config = await resolveConfig({
serverUrl: 'http://test',
password: 'pw',
dataDir: '/custom/dir',
});
expect(config.dataDir).toBe('/custom/dir');
});
});
describe('validation', () => {
it('throws when serverUrl is missing', async () => {
await expect(resolveConfig({ password: 'pw' })).rejects.toThrow(
'Server URL is required',
);
});
it('throws when neither password nor sessionToken provided', async () => {
await expect(resolveConfig({ serverUrl: 'http://test' })).rejects.toThrow(
'Authentication required',
);
});
it('accepts sessionToken without password', async () => {
const config = await resolveConfig({
serverUrl: 'http://test',
sessionToken: 'tok',
});
expect(config.sessionToken).toBe('tok');
expect(config.password).toBeUndefined();
});
it('accepts password without sessionToken', async () => {
const config = await resolveConfig({
serverUrl: 'http://test',
password: 'pw',
});
expect(config.password).toBe('pw');
expect(config.sessionToken).toBeUndefined();
});
});
describe('cosmiconfig handling', () => {
it('handles null result (no config file found)', async () => {
mockConfigFile(null);
const config = await resolveConfig({
serverUrl: 'http://test',
password: 'pw',
});
expect(config.serverUrl).toBe('http://test');
});
it('handles isEmpty result', async () => {
mockSearch.mockResolvedValue({ config: {}, isEmpty: true });
const config = await resolveConfig({
serverUrl: 'http://test',
password: 'pw',
});
expect(config.serverUrl).toBe('http://test');
});
});
});

141
packages/cli/src/config.ts Normal file
View File

@@ -0,0 +1,141 @@
import { homedir } from 'os';
import { join } from 'path';
import { cosmiconfig } from 'cosmiconfig';
export type CliConfig = {
serverUrl: string;
password?: string;
sessionToken?: string;
syncId?: string;
dataDir: string;
encryptionPassword?: string;
};
export type CliGlobalOpts = {
serverUrl?: string;
password?: string;
sessionToken?: string;
syncId?: string;
dataDir?: string;
encryptionPassword?: string;
format?: 'json' | 'table' | 'csv';
verbose?: boolean;
};
type ConfigFileContent = {
serverUrl?: string;
password?: string;
sessionToken?: string;
syncId?: string;
dataDir?: string;
encryptionPassword?: string;
};
const configFileKeys: readonly string[] = [
'serverUrl',
'password',
'sessionToken',
'syncId',
'dataDir',
'encryptionPassword',
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validateConfigFileContent(value: unknown): ConfigFileContent {
if (!isRecord(value)) {
throw new Error(
'Invalid config file: expected an object with keys: ' +
configFileKeys.join(', '),
);
}
for (const key of Object.keys(value)) {
if (!configFileKeys.includes(key)) {
throw new Error(`Invalid config file: unknown key "${key}"`);
}
if (value[key] !== undefined && typeof value[key] !== 'string') {
throw new Error(
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
);
}
}
return value as ConfigFileContent;
}
async function loadConfigFile(): Promise<ConfigFileContent> {
const explorer = cosmiconfig('actual', {
searchPlaces: [
'package.json',
'.actualrc',
'.actualrc.json',
'.actualrc.yaml',
'.actualrc.yml',
'actual.config.json',
'actual.config.yaml',
'actual.config.yml',
],
});
const result = await explorer.search();
if (result && !result.isEmpty) {
return validateConfigFileContent(result.config);
}
return {};
}
export async function resolveConfig(
cliOpts: CliGlobalOpts,
): Promise<CliConfig> {
const fileConfig = await loadConfigFile();
const serverUrl =
cliOpts.serverUrl ??
process.env.ACTUAL_SERVER_URL ??
fileConfig.serverUrl ??
'';
const password =
cliOpts.password ?? process.env.ACTUAL_PASSWORD ?? fileConfig.password;
const sessionToken =
cliOpts.sessionToken ??
process.env.ACTUAL_SESSION_TOKEN ??
fileConfig.sessionToken;
const syncId =
cliOpts.syncId ?? process.env.ACTUAL_SYNC_ID ?? fileConfig.syncId;
const dataDir =
cliOpts.dataDir ??
process.env.ACTUAL_DATA_DIR ??
fileConfig.dataDir ??
join(homedir(), '.actual-cli', 'data');
const encryptionPassword =
cliOpts.encryptionPassword ??
process.env.ACTUAL_ENCRYPTION_PASSWORD ??
fileConfig.encryptionPassword;
if (!serverUrl) {
throw new Error(
'Server URL is required. Set --server-url, ACTUAL_SERVER_URL env var, or serverUrl in config file.',
);
}
if (!password && !sessionToken) {
throw new Error(
'Authentication required. Set --password/--session-token, ACTUAL_PASSWORD/ACTUAL_SESSION_TOKEN env var, or password/sessionToken in config file.',
);
}
return {
serverUrl,
password,
sessionToken,
syncId,
dataDir,
encryptionPassword,
};
}

View File

@@ -0,0 +1,134 @@
import * as api from '@actual-app/api';
import { resolveConfig } from './config';
import { withConnection } from './connection';
vi.mock('@actual-app/api', () => ({
init: vi.fn().mockResolvedValue(undefined),
downloadBudget: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./config', () => ({
resolveConfig: vi.fn(),
}));
function setConfig(overrides: Record<string, unknown> = {}) {
vi.mocked(resolveConfig).mockResolvedValue({
serverUrl: 'http://test',
password: 'pw',
dataDir: '/tmp/data',
syncId: 'budget-1',
...overrides,
});
}
describe('withConnection', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
setConfig();
});
afterEach(() => {
stderrSpy.mockRestore();
});
it('calls api.init with password when no sessionToken', async () => {
setConfig({ password: 'pw', sessionToken: undefined });
await withConnection({}, async () => 'ok');
expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test',
password: 'pw',
dataDir: '/tmp/data',
verbose: undefined,
});
});
it('calls api.init with sessionToken when present', async () => {
setConfig({ sessionToken: 'tok', password: undefined });
await withConnection({}, async () => 'ok');
expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test',
sessionToken: 'tok',
dataDir: '/tmp/data',
verbose: undefined,
});
});
it('calls api.downloadBudget when syncId is set', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok');
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
password: undefined,
});
});
it('throws when loadBudget is true but syncId is not set', async () => {
setConfig({ syncId: undefined });
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
'Sync ID is required',
);
});
it('skips budget download when loadBudget is false and syncId is not set', async () => {
setConfig({ syncId: undefined });
await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('does not call api.downloadBudget when loadBudget is false', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('returns callback result', async () => {
const result = await withConnection({}, async () => 42);
expect(result).toBe(42);
});
it('calls api.shutdown in finally block on success', async () => {
await withConnection({}, async () => 'ok');
expect(api.shutdown).toHaveBeenCalled();
});
it('calls api.shutdown in finally block on error', async () => {
await expect(
withConnection({}, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(api.shutdown).toHaveBeenCalled();
});
it('does not write to stderr by default', async () => {
await withConnection({}, async () => 'ok');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('writes info to stderr when verbose', async () => {
await withConnection({ verbose: true }, async () => 'ok');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Connecting to'),
);
});
});

View File

@@ -0,0 +1,65 @@
import { mkdirSync } from 'fs';
import * as api from '@actual-app/api';
import { resolveConfig } from './config';
import type { CliGlobalOpts } from './config';
function info(message: string, verbose?: boolean) {
if (verbose) {
process.stderr.write(message + '\n');
}
}
type ConnectionOptions = {
loadBudget?: boolean;
};
export async function withConnection<T>(
globalOpts: CliGlobalOpts,
fn: () => Promise<T>,
options: ConnectionOptions = {},
): Promise<T> {
const { loadBudget = true } = options;
const config = await resolveConfig(globalOpts);
mkdirSync(config.dataDir, { recursive: true });
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
if (config.sessionToken) {
await api.init({
serverURL: config.serverUrl,
dataDir: config.dataDir,
sessionToken: config.sessionToken,
verbose: globalOpts.verbose,
});
} else if (config.password) {
await api.init({
serverURL: config.serverUrl,
dataDir: config.dataDir,
password: config.password,
verbose: globalOpts.verbose,
});
} else {
throw new Error(
'Authentication required. Provide --password or --session-token, or set ACTUAL_PASSWORD / ACTUAL_SESSION_TOKEN.',
);
}
try {
if (loadBudget && config.syncId) {
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
await api.downloadBudget(config.syncId, {
password: config.encryptionPassword,
});
} else if (loadBudget && !config.syncId) {
throw new Error(
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
);
}
return await fn();
} finally {
await api.shutdown();
}
}

70
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,70 @@
import { Command, Option } from 'commander';
import { registerAccountsCommand } from './commands/accounts';
import { registerBudgetsCommand } from './commands/budgets';
import { registerCategoriesCommand } from './commands/categories';
import { registerCategoryGroupsCommand } from './commands/category-groups';
import { registerPayeesCommand } from './commands/payees';
import { registerQueryCommand } from './commands/query';
import { registerRulesCommand } from './commands/rules';
import { registerSchedulesCommand } from './commands/schedules';
import { registerServerCommand } from './commands/server';
import { registerTagsCommand } from './commands/tags';
import { registerTransactionsCommand } from './commands/transactions';
declare const __CLI_VERSION__: string;
const program = new Command();
program
.name('actual')
.description('CLI for Actual Budget')
.version(__CLI_VERSION__)
.option('--server-url <url>', 'Actual server URL (env: ACTUAL_SERVER_URL)')
.option('--password <password>', 'Server password (env: ACTUAL_PASSWORD)')
.option(
'--session-token <token>',
'Session token (env: ACTUAL_SESSION_TOKEN)',
)
.option('--sync-id <id>', 'Budget sync ID (env: ACTUAL_SYNC_ID)')
.option('--data-dir <path>', 'Data directory (env: ACTUAL_DATA_DIR)')
.option(
'--encryption-password <password>',
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
)
.addOption(
new Option('--format <format>', 'Output format: json, table, csv')
.choices(['json', 'table', 'csv'] as const)
.default('json'),
)
.option('--verbose', 'Show informational messages', false);
registerAccountsCommand(program);
registerBudgetsCommand(program);
registerCategoriesCommand(program);
registerCategoryGroupsCommand(program);
registerTransactionsCommand(program);
registerPayeesCommand(program);
registerTagsCommand(program);
registerRulesCommand(program);
registerSchedulesCommand(program);
registerQueryCommand(program);
registerServerCommand(program);
function normalizeThrownMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'object' && err !== null) {
try {
return JSON.stringify(err);
} catch {
return '<non-serializable error>';
}
}
return String(err);
}
program.parseAsync(process.argv).catch((err: unknown) => {
const message = normalizeThrownMessage(err);
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 1;
});

21
packages/cli/src/input.ts Normal file
View File

@@ -0,0 +1,21 @@
import { readFileSync } from 'fs';
export function readJsonInput(cmdOpts: {
data?: string;
file?: string;
}): unknown {
if (cmdOpts.data && cmdOpts.file) {
throw new Error('Cannot use both --data and --file');
}
if (cmdOpts.data) {
return JSON.parse(cmdOpts.data);
}
if (cmdOpts.file) {
const content =
cmdOpts.file === '-'
? readFileSync(0, 'utf-8')
: readFileSync(cmdOpts.file, 'utf-8');
return JSON.parse(content);
}
throw new Error('Either --data or --file is required');
}

View File

@@ -0,0 +1,152 @@
import { formatOutput, printOutput } from './output';
describe('formatOutput', () => {
describe('json (default)', () => {
it('pretty-prints with 2-space indent', () => {
const data = { a: 1, b: 'two' };
expect(formatOutput(data)).toBe(JSON.stringify(data, null, 2));
});
it('is the default format', () => {
expect(formatOutput({ x: 1 })).toBe(formatOutput({ x: 1 }, 'json'));
});
it('handles arrays', () => {
const data = [1, 2, 3];
expect(formatOutput(data, 'json')).toBe('[\n 1,\n 2,\n 3\n]');
});
it('handles null', () => {
expect(formatOutput(null, 'json')).toBe('null');
});
});
describe('table', () => {
it('renders an object as key-value table', () => {
const result = formatOutput({ name: 'Alice', age: 30 }, 'table');
expect(result).toContain('name');
expect(result).toContain('Alice');
expect(result).toContain('age');
expect(result).toContain('30');
});
it('renders an array of objects as columnar table', () => {
const data = [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
];
const result = formatOutput(data, 'table');
expect(result).toContain('id');
expect(result).toContain('name');
expect(result).toContain('1');
expect(result).toContain('a');
expect(result).toContain('2');
expect(result).toContain('b');
});
it('returns "(no results)" for empty array', () => {
expect(formatOutput([], 'table')).toBe('(no results)');
});
it('returns String(data) for scalar values', () => {
expect(formatOutput(42, 'table')).toBe('42');
expect(formatOutput('hello', 'table')).toBe('hello');
expect(formatOutput(true, 'table')).toBe('true');
});
it('handles null/undefined values in objects', () => {
const data = [{ a: null, b: undefined }];
const result = formatOutput(data, 'table');
expect(result).toContain('a');
expect(result).toContain('b');
});
});
describe('csv', () => {
it('renders array of objects as header + data rows', () => {
const data = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
const result = formatOutput(data, 'csv');
const lines = result.split('\n');
expect(lines[0]).toBe('id,name');
expect(lines[1]).toBe('1,Alice');
expect(lines[2]).toBe('2,Bob');
});
it('renders single object as header + single row', () => {
const result = formatOutput({ x: 10, y: 20 }, 'csv');
const lines = result.split('\n');
expect(lines[0]).toBe('x,y');
expect(lines[1]).toBe('10,20');
});
it('returns empty string for empty array', () => {
expect(formatOutput([], 'csv')).toBe('');
});
it('returns String(data) for scalar values', () => {
expect(formatOutput(42, 'csv')).toBe('42');
expect(formatOutput('hello', 'csv')).toBe('hello');
});
it('escapes commas by quoting', () => {
const data = [{ val: 'a,b' }];
expect(formatOutput(data, 'csv')).toBe('val\n"a,b"');
});
it('escapes double quotes by doubling them', () => {
const data = [{ val: 'say "hi"' }];
expect(formatOutput(data, 'csv')).toBe('val\n"say ""hi"""');
});
it('escapes newlines by quoting', () => {
const data = [{ val: 'line1\nline2' }];
expect(formatOutput(data, 'csv')).toBe('val\n"line1\nline2"');
});
it('handles null/undefined values', () => {
const data = [{ a: null, b: undefined }];
const result = formatOutput(data, 'csv');
const lines = result.split('\n');
expect(lines[0]).toBe('a,b');
});
});
});
describe('printOutput', () => {
let writeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
});
afterEach(() => {
writeSpy.mockRestore();
});
it('writes formatted output followed by newline', () => {
printOutput({ a: 1 }, 'json');
expect(writeSpy).toHaveBeenCalledWith(
JSON.stringify({ a: 1 }, null, 2) + '\n',
);
});
it('defaults to json format', () => {
printOutput([1, 2]);
expect(writeSpy).toHaveBeenCalledWith(
JSON.stringify([1, 2], null, 2) + '\n',
);
});
it('supports table format', () => {
printOutput([], 'table');
expect(writeSpy).toHaveBeenCalledWith('(no results)\n');
});
it('supports csv format', () => {
printOutput([], 'csv');
expect(writeSpy).toHaveBeenCalledWith('\n');
});
});

View File

@@ -0,0 +1,82 @@
import Table from 'cli-table3';
export type OutputFormat = 'json' | 'table' | 'csv';
export function formatOutput(
data: unknown,
format: OutputFormat = 'json',
): string {
switch (format) {
case 'json':
return JSON.stringify(data, null, 2);
case 'table':
return formatTable(data);
case 'csv':
return formatCsv(data);
default:
return JSON.stringify(data, null, 2);
}
}
function formatTable(data: unknown): string {
if (!Array.isArray(data)) {
if (data && typeof data === 'object') {
const table = new Table();
for (const [key, value] of Object.entries(data)) {
table.push({ [key]: String(value) });
}
return table.toString();
}
return String(data);
}
if (data.length === 0) {
return '(no results)';
}
const keys = Object.keys(data[0] as Record<string, unknown>);
const table = new Table({ head: keys });
for (const row of data) {
const r = row as Record<string, unknown>;
table.push(keys.map(k => String(r[k] ?? '')));
}
return table.toString();
}
function formatCsv(data: unknown): string {
if (!Array.isArray(data)) {
if (data && typeof data === 'object') {
const entries = Object.entries(data);
const header = entries.map(([k]) => escapeCsv(k)).join(',');
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
return header + '\n' + values;
}
return String(data);
}
if (data.length === 0) {
return '';
}
const keys = Object.keys(data[0] as Record<string, unknown>);
const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => {
const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
});
return [header, ...rows].join('\n');
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
export function printOutput(data: unknown, format: OutputFormat = 'json') {
process.stdout.write(formatOutput(data, format) + '\n');
}

View File

@@ -0,0 +1,65 @@
import { parseBoolFlag, parseIntFlag } from './utils';
describe('parseBoolFlag', () => {
it('parses "true"', () => {
expect(parseBoolFlag('true', '--flag')).toBe(true);
});
it('parses "false"', () => {
expect(parseBoolFlag('false', '--flag')).toBe(false);
});
it('rejects other strings', () => {
expect(() => parseBoolFlag('yes', '--flag')).toThrow(
'Invalid --flag: "yes". Expected "true" or "false".',
);
});
it('includes the flag name in the error message', () => {
expect(() => parseBoolFlag('1', '--offbudget')).toThrow(
'Invalid --offbudget',
);
});
});
describe('parseIntFlag', () => {
it('parses a valid integer string', () => {
expect(parseIntFlag('42', '--balance')).toBe(42);
});
it('parses zero', () => {
expect(parseIntFlag('0', '--balance')).toBe(0);
});
it('parses negative integers', () => {
expect(parseIntFlag('-10', '--balance')).toBe(-10);
});
it('rejects decimal values', () => {
expect(() => parseIntFlag('3.5', '--balance')).toThrow(
'Invalid --balance: "3.5". Expected an integer.',
);
});
it('rejects non-numeric strings', () => {
expect(() => parseIntFlag('abc', '--balance')).toThrow(
'Invalid --balance: "abc". Expected an integer.',
);
});
it('rejects partially numeric strings', () => {
expect(() => parseIntFlag('3abc', '--balance')).toThrow(
'Invalid --balance: "3abc". Expected an integer.',
);
});
it('rejects empty string', () => {
expect(() => parseIntFlag('', '--balance')).toThrow(
'Invalid --balance: "". Expected an integer.',
);
});
it('includes the flag name in the error message', () => {
expect(() => parseIntFlag('x', '--amount')).toThrow('Invalid --amount');
});
});

16
packages/cli/src/utils.ts Normal file
View File

@@ -0,0 +1,16 @@
export function parseBoolFlag(value: string, flagName: string): boolean {
if (value !== 'true' && value !== 'false') {
throw new Error(
`Invalid ${flagName}: "${value}". Expected "true" or "false".`,
);
}
return value === 'true';
}
export function parseIntFlag(value: string, flagName: string): number {
const parsed = value.trim() === '' ? NaN : Number(value);
if (!Number.isInteger(parsed)) {
throw new Error(`Invalid ${flagName}: "${value}". Expected an integer.`);
}
return parsed;
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2021"],
"types": ["vitest/globals", "node"],
"noEmit": false,
"strict": true,
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [{ "path": "../api" }],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "coverage"]
}

View File

@@ -0,0 +1,36 @@
import fs from 'fs';
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
const pkg = JSON.parse(
fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
);
export default defineConfig({
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
ssr: { noExternal: true, external: ['@actual-app/api'] },
build: {
ssr: true,
target: 'node22',
outDir: path.resolve(__dirname, 'dist'),
emptyOutDir: true,
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
formats: ['es'],
},
rollupOptions: {
output: {
entryFileNames: 'cli.js',
banner: chunk => (chunk.isEntry ? '#!/usr/bin/env node' : ''),
},
},
},
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
test: {
globals: true,
},
});

View File

@@ -53,7 +53,7 @@
"@storybook/addon-docs": "^10.2.7",
"@storybook/react-vite": "^10.2.7",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"@types/react": "^19.2.14",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint-plugin-storybook": "^10.2.7",

View File

@@ -4,7 +4,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type BlockProps = HTMLProps<HTMLDivElement> & {
type BlockProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
innerRef?: Ref<HTMLDivElement>;
style?: CSSProperties;
};

View File

@@ -4,7 +4,7 @@ import { css } from '@emotion/css';
import type { CSSProperties } from './styles';
type ParagraphProps = HTMLProps<HTMLDivElement> & {
type ParagraphProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
style?: CSSProperties;
isLast?: boolean;
};

View File

@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type TextProps = HTMLProps<HTMLSpanElement> & {
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'style'> & {
innerRef?: Ref<HTMLSpanElement>;
className?: string;
children?: ReactNode;

View File

@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type ViewProps = HTMLProps<HTMLDivElement> & {
type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
className?: string;
style?: CSSProperties;
nativeStyle?: CSSProperties;

View File

@@ -45,7 +45,7 @@
"@types/lodash": "^4",
"@types/pikaday": "^1.7.10",
"@types/promise-retry": "^1.1.6",
"@types/react": "^19.2.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
@@ -86,7 +86,7 @@
"react-redux": "^9.2.0",
"react-router": "7.13.0",
"react-simple-pull-to-refresh": "^1.3.4",
"react-spring": "10.0.0",
"react-spring": "^10.0.3",
"react-swipeable": "^7.0.2",
"react-virtualized-auto-sizer": "^2.0.2",
"recharts": "^3.7.0",

View File

@@ -6,6 +6,8 @@ import * as Platform from 'loot-core/shared/platform';
// oxlint-disable-next-line typescript-paths/absolute-parent-import
import packageJson from '../package.json';
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
// This file installs global variables that the app expects.
@@ -21,9 +23,235 @@ const ACTUAL_VERSION = Platform.isPlaywright
: packageJson.version;
// *** Start the backend ***
let worker = null;
// The regular Worker running the backend, created only on the leader tab
let localBackendWorker = null;
/**
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
* (onmessage, postMessage, addEventListener, start) to the connection layer.
*
* The SharedWorker coordinator assigns each tab a role per budget:
* - LEADER: this tab runs the backend in a dedicated Worker
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
*
* Multiple budgets can be open simultaneously — each has its own leader.
*/
class WorkerBridge {
constructor(sharedPort) {
this._sharedPort = sharedPort;
this._onmessage = null;
this._listeners = [];
this._started = false;
// Listen for all messages from the SharedWorker port
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
}
set onmessage(handler) {
this._onmessage = handler;
// Setting onmessage on a real MessagePort implicitly starts it.
// We need to do this explicitly on the underlying port.
if (!this._started) {
this._started = true;
this._sharedPort.start();
}
}
get onmessage() {
return this._onmessage;
}
postMessage(msg) {
// All messages go through the SharedWorker for coordination.
// The SharedWorker forwards to the leader's Worker via __to-worker.
this._sharedPort.postMessage(msg);
}
addEventListener(type, handler) {
this._listeners.push({ type, handler });
}
start() {
if (!this._started) {
this._started = true;
this._sharedPort.start();
}
}
_dispatch(event) {
if (this._onmessage) this._onmessage(event);
for (const { type, handler } of this._listeners) {
if (type === 'message') handler(event);
}
}
_onSharedMessage(event) {
const msg = event.data;
// Elected as leader: create the real backend Worker on this tab
if (msg && msg.type === '__become-leader') {
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
return;
}
// Forward requests from SharedWorker to our local Worker
if (msg && msg.type === '__to-worker') {
if (localBackendWorker) {
localBackendWorker.postMessage(msg.msg);
}
return;
}
// Leadership transfer: this tab is closing the budget but other tabs
// still need it. Terminate our Worker (don't actually close-budget on
// the backend) and dispatch a synthetic reply so the UI navigates to
// show-budgets normally.
if (msg && msg.type === '__close-and-transfer') {
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
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).
if (msg.requestId) {
this._dispatch({
data: { type: 'reply', id: msg.requestId, data: {} },
});
}
return;
}
// Role change notification
if (msg && msg.type === '__role-change') {
console.log(
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
);
return;
}
// Surface SharedWorker console output in this tab's DevTools
if (msg && msg.type === '__shared-worker-console') {
const method = console[msg.level] || console.log;
method(...msg.args);
return;
}
// Respond to heartbeat pings
if (msg && msg.type === '__heartbeat-ping') {
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
return;
}
// Everything else goes to the connection layer
this._dispatch(event);
}
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
if (localBackendWorker) {
localBackendWorker.terminate();
}
localBackendWorker = new Worker(backendWorkerUrl);
initSQLBackend(localBackendWorker);
const sharedPort = this._sharedPort;
localBackendWorker.onmessage = workerEvent => {
const workerMsg = workerEvent.data;
// absurd-sql internal messages are handled by initSQLBackend
if (
workerMsg &&
workerMsg.type &&
workerMsg.type.startsWith('__absurd:')
) {
return;
}
// After the backend connects, automatically reload the budget that was
// open before the leader left (e.g. page refresh). This lets other tabs
// continue working without being sent to the budget list.
if (workerMsg.type === 'connect') {
if (budgetToRestore) {
console.log(
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
);
const id = budgetToRestore;
budgetToRestore = null;
localBackendWorker.postMessage({
id: '__restore-budget',
name: 'load-budget',
args: { id },
catchErrors: true,
});
// Tell SharedWorker to track the restore request so
// currentBudgetId gets updated when the reply arrives.
sharedPort.postMessage({
type: '__track-restore',
requestId: '__restore-budget',
budgetId: id,
});
} else if (pendingMsg) {
const toSend = pendingMsg;
pendingMsg = null;
localBackendWorker.postMessage(toSend);
}
}
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
};
localBackendWorker.postMessage(initMsg);
}
}
function createBackendWorker() {
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
// Each budget gets its own leader tab running a dedicated Worker. All other
// tabs on the same budget are followers — their messages are routed through
// the SharedWorker to the leader's Worker.
// The SharedWorker never touches SharedArrayBuffer, so this works on all
// platforms including iOS/Safari.
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
try {
const sharedWorker = new SharedBrowserServerWorker({
name: 'actual-backend',
});
const sharedPort = sharedWorker.port;
worker = new WorkerBridge(sharedPort);
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
// Don't call start() here. The port must remain un-started so that
// messages (especially 'connect') are queued until connectWorker()
// sets onmessage, which implicitly starts the port via the bridge.
if (window.SharedArrayBuffer) {
localStorage.removeItem('SharedArrayBufferOverride');
}
sharedPort.postMessage({
type: 'init',
version: ACTUAL_VERSION,
isDev: IS_DEV,
publicUrl: process.env.PUBLIC_URL,
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
'SharedArrayBufferOverride',
),
});
window.addEventListener('beforeunload', () => {
sharedPort.postMessage({ type: 'tab-closing' });
});
return;
} catch (e) {
console.log('SharedWorker failed, falling back to Worker:', e);
}
}
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
worker = new Worker(backendWorkerUrl);
initSQLBackend(worker);
@@ -37,6 +265,7 @@ function createBackendWorker() {
isDev: IS_DEV,
publicUrl: process.env.PUBLIC_URL,
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
'SharedArrayBufferOverride',
),

View File

@@ -1,16 +1,16 @@
// @ts-strict-ignore
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } 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 { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
@@ -30,9 +30,7 @@ import { RulesList } from './rules/RulesList';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { usePayeeRules } from '@desktop-client/hooks/usePayeeRules';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useRules } from '@desktop-client/hooks/useRules';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import {
SelectedProvider,
@@ -40,10 +38,6 @@ import {
} from '@desktop-client/hooks/useSelected';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
import {
useBatchDeleteRulesMutation,
useDeleteRuleMutation,
} from '@desktop-client/rules';
export type FilterData = {
payees?: Array<{ id: string; name: string }>;
@@ -121,36 +115,17 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
type ManageRulesProps = {
isModal: boolean;
payeeId: string | null;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
export function ManageRules({
isModal,
payeeId,
setLoading = () => {},
}: ManageRulesProps) {
const { t } = useTranslation();
const {
data: allRules = [],
refetch: refetchAllRules,
isLoading: isAllRulesLoading,
isRefetching: isAllRulesRefetching,
} = useRules({
enabled: !payeeId,
});
const {
data: payeeRules = [],
refetch: refetchPayeeRules,
isLoading: isPayeeRulesLoading,
isRefetching: isPayeeRulesRefetching,
} = usePayeeRules({
payeeId,
});
const rulesToUse = payeeId ? payeeRules : allRules;
const refetchRules = payeeId ? refetchPayeeRules : refetchAllRules;
const isLoading =
isAllRulesLoading ||
isAllRulesRefetching ||
isPayeeRulesLoading ||
isPayeeRulesRefetching;
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
@@ -172,7 +147,7 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
);
const filteredRules = useMemo(() => {
const rules = rulesToUse.filter(rule => {
const rules = allRules.filter(rule => {
const schedule = schedules.find(schedule => schedule.rule === rule.id);
return schedule ? schedule.completed === false : true;
});
@@ -186,7 +161,7 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
),
)
).slice(0, 100 + page * 50);
}, [rulesToUse, filter, filterData, page, schedules]);
}, [allRules, filter, filterData, page, schedules]);
const selectedInst = useSelected('manage-rules', filteredRules, []);
const [hoveredRule, setHoveredRule] = useState(null);
@@ -196,16 +171,38 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
setPage(0);
};
async function loadRules() {
setLoading(true);
let loadedRules = null;
if (payeeId) {
loadedRules = await send('payees-get-rules', {
id: payeeId,
});
} else {
loadedRules = await send('rules-get');
}
setAllRules(loadedRules);
return loadedRules;
}
const init = useEffectEvent(() => {
async function loadData() {
await loadRules();
setLoading(false);
}
if (payeeId) {
undo.setUndoState('openModal', { name: 'manage-rules', options: {} });
}
void loadData();
return () => {
undo.setUndoState('openModal', null);
};
});
useEffect(() => {
return init();
}, []);
@@ -214,33 +211,29 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
setPage(page => page + 1);
}
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
const onDeleteSelected = async () => {
batchDeleteRules(
{
ids: [...selectedInst.items],
},
{
onSuccess: () => {
void refetchRules();
selectedInst.dispatch({ type: 'select-none' });
},
},
);
setLoading(true);
const { someDeletionsFailed } = await send('rule-delete-all', [
...selectedInst.items,
]);
if (someDeletionsFailed) {
alert(
t('Some rules were not deleted because they are linked to schedules.'),
);
}
await loadRules();
selectedInst.dispatch({ type: 'select-none' });
setLoading(false);
};
const { mutate: deleteRule } = useDeleteRuleMutation();
function onDeleteRule(id: string) {
deleteRule(
{ id },
{
onSuccess: () => {
void refetchRules();
},
},
);
async function onDeleteRule(id: string) {
setLoading(true);
await send('rule-delete', id);
await loadRules();
setLoading(false);
}
const onEditRule = rule => {
@@ -251,7 +244,8 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
options: {
rule,
onSave: async () => {
void refetchRules();
await loadRules();
setLoading(false);
},
},
},
@@ -288,7 +282,8 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
options: {
rule,
onSave: async () => {
void refetchRules();
await loadRules();
setLoading(false);
},
},
},
@@ -300,24 +295,6 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
setHoveredRule(id);
};
if (isLoading) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading width={25} height={25} />
</View>
);
}
const isNonDeletableRuleSelected = schedules.some(schedule =>
selectedInst.items.has(schedule.rule),
);
return (
<SelectedProvider instance={selectedInst}>
<View>
@@ -384,24 +361,11 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
>
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
{selectedInst.items.size > 0 && (
<Tooltip
isOpen={isNonDeletableRuleSelected}
content={
<Trans>
Some selected rules cannot be deleted because they are
linked to schedules.
</Trans>
}
>
<Button
onPress={onDeleteSelected}
isDisabled={isNonDeletableRuleSelected}
>
<Trans count={selectedInst.items.size}>
Delete {{ count: selectedInst.items.size }} rules
</Trans>
</Button>
</Tooltip>
<Button onPress={onDeleteSelected}>
<Trans count={selectedInst.items.size}>
Delete {{ count: selectedInst.items.size }} rules
</Trans>
</Button>
)}
<Button variant="primary" onPress={onCreateRule}>
<Trans>Create new rule</Trans>

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { ComponentProps, CSSProperties } from 'react';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCustomNotesPaper } from '@actual-app/components/icons/v2';
import { Popover } from '@actual-app/components/popover';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';

View File

@@ -153,22 +153,27 @@ function Notification({
const yOffset = index * Y_OFFSET_PER_LEVEL;
const [isSwiped, setIsSwiped] = useState(false);
const [spring, api] = useSpring(() => ({
x: 0,
y: yOffset,
opacity: stackOpacity,
scale,
}));
const [spring, api] = useSpring(
() => ({
from: {
x: 0,
y: yOffset,
opacity: stackOpacity,
scale,
},
}),
[],
);
// Update scale, opacity, and y-position when index changes
useEffect(() => {
void api.start({ scale, opacity: stackOpacity, y: yOffset });
void api.start({ to: { scale, opacity: stackOpacity, y: yOffset } });
}, [index, scale, stackOpacity, yOffset, api]);
const swipeHandlers = useSwipeable({
onSwiping: ({ deltaX }) => {
if (!isSwiped) {
void api.start({ x: deltaX });
void api.start({ to: { x: deltaX } });
}
},
onSwiped: ({ velocity, deltaX }) => {
@@ -179,14 +184,13 @@ function Notification({
if (Math.abs(deltaX) > threshold || velocity > 0.5) {
// Animate out & remove item after animation
void api.start({
x: direction * 1000,
opacity: 0,
to: { x: direction * 1000, opacity: 0 },
onRest: onRemove,
});
setIsSwiped(true);
} else {
// Reset position if not swiped far enough
void api.start({ x: 0 });
void api.start({ to: { x: 0 } });
}
},
trackMouse: true,

View File

@@ -83,7 +83,6 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
@@ -252,7 +251,6 @@ type AccountInternalProps = {
onUnlinkAccount: (id: AccountEntity['id']) => void;
onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
onRunRules: (transaction: TransactionEntity) => Promise<TransactionEntity>;
};
type AccountInternalState = {
@@ -693,8 +691,9 @@ class AccountInternal extends PureComponent<
const allErrors: string[] = [];
for (const transaction of transactions) {
const res: TransactionEntity | null =
await this.props.onRunRules(transaction);
const res: TransactionEntity | null = await send('rules-run', {
transaction,
});
if (res) {
changedTransactions.push(...ungroupTransaction(res));
@@ -1056,9 +1055,10 @@ class AccountInternal extends PureComponent<
});
// run rules on the reconciliation transaction
const runRules = this.props.onRunRules;
const ruledTransactions = await Promise.all(
reconciliationTransactions.map(transaction => runRules(transaction)),
reconciliationTransactions.map(transaction =>
send('rules-run', { transaction }),
),
);
// sync the reconciliation transaction
@@ -2028,13 +2028,9 @@ export function Account() {
const onSyncAndDownload = (id?: AccountEntity['id']) =>
syncAndDownload({ id });
const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
const createPayee = useCreatePayeeMutation();
const onCreatePayee = (name: PayeeEntity['name']) =>
createPayeeAsync({ name });
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onRunRules = (transaction: TransactionEntity) =>
runRulesAsync({ transaction });
createPayee.mutateAsync({ name });
return (
<SchedulesProvider query={schedulesQuery}>
@@ -2077,7 +2073,6 @@ export function Account() {
onUnlinkAccount={onUnlinkAccount}
onSyncAndDownload={onSyncAndDownload}
onCreatePayee={onCreatePayee}
onRunRules={onRunRules}
/>
</SplitsExpandedProvider>
</SchedulesProvider>

View File

@@ -24,10 +24,13 @@ export function BudgetSummaries() {
const [firstMonth] = months;
const [widthState, setWidthState] = useState(0);
const [styles, spring] = useSpring(() => ({
x: 0,
config: { mass: 3, tension: 600, friction: 80 },
}));
const [styles, spring] = useSpring(
() => ({
from: { x: 0 },
config: { mass: 3, tension: 600, friction: 80 },
}),
[],
);
const containerRef = useResizeObserver<HTMLDivElement>(
useCallback(rect => {
@@ -55,7 +58,9 @@ export function BudgetSummaries() {
}
const to = -offsetX;
void spring.start({ from: { x: from }, x: to });
if (from !== to) {
void spring.start({ from: { x: from }, to: { x: to } });
}
}, [spring, firstMonth, monthWidth, allMonths]);
useLayoutEffect(() => {
@@ -63,7 +68,7 @@ export function BudgetSummaries() {
}, [firstMonth]);
useLayoutEffect(() => {
void spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
void spring.start({ to: { x: -monthWidth }, immediate: true });
}, [spring, monthWidth]);
const { SummaryComponent } = useBudgetComponents();

View File

@@ -1,9 +1,9 @@
import React from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgChartPie } from '@actual-app/components/icons/v1';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { css, cx } from '@emotion/css';

View File

@@ -1,7 +1,6 @@
// @ts-strict-ignore
import type { CSSProperties } from 'react';
import { styles } from '@actual-app/components/styles';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { t } from 'i18next';

View File

@@ -17,8 +17,12 @@ type TextLinkProps = {
children?: ReactNode;
};
type ButtonLinkProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
type ButtonLinkProps = Omit<
ComponentProps<typeof Button>,
'variant' | 'style'
> & {
buttonVariant?: ComponentProps<typeof Button>['variant'];
style?: CSSProperties;
to?: string;
activeStyle?: CSSProperties;
};

View File

@@ -28,7 +28,7 @@ import {
getFieldError,
getValidOps,
mapField,
unparseConditions,
unparse,
} from 'loot-core/shared/rules';
import { titleFirst } from 'loot-core/shared/util';
import type { IntegerAmount } from 'loot-core/shared/util';
@@ -296,39 +296,37 @@ function ConfigureField<T extends RuleConditionEntity>({
});
}}
>
{type &&
type !== 'boolean' &&
(field !== 'payee' || !isPayeeIdOp(op)) && (
<GenericInput
ref={inputRef}
field={
field === 'date' || field === 'category' ? subfield : field
}
type={
type === 'id' &&
(op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain' ||
op === 'hasTags')
? 'string'
: type
}
numberFormatType="currency"
// @ts-expect-error - fix me
value={
formattedValue ??
(op === 'oneOf' || op === 'notOneOf' ? [] : '')
}
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
options={subfieldToOptions(field, subfield)}
style={{ marginTop: 10 }}
// oxlint-disable-next-line typescript/no-explicit-any
onChange={(v: any) => {
dispatch({ type: 'set-value', value: v });
}}
/>
)}
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
<GenericInput
ref={inputRef}
// @ts-expect-error - fix me
field={field === 'date' || field === 'category' ? subfield : field}
// @ts-expect-error - fix me
type={
type === 'id' &&
(op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain' ||
op === 'hasTags')
? 'string'
: type
}
numberFormatType="currency"
// @ts-expect-error - fix me
value={
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
}
// @ts-expect-error - fix me
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
options={subfieldToOptions(field, subfield)}
style={{ marginTop: 10 }}
// oxlint-disable-next-line typescript/no-explicit-any
onChange={(v: any) => {
dispatch({ type: 'set-value', value: v });
}}
/>
)}
{field === 'payee' && isPayeeIdOp(op) && (
<PayeeFilter
@@ -426,7 +424,7 @@ export function FilterButton<T extends RuleConditionEntity>({
async function onValidateAndApply(cond: T) {
// @ts-expect-error - fix me
cond = unparseConditions({ ...cond, type: FIELD_TYPES.get(cond.field) });
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (cond.options.month) {
@@ -630,11 +628,7 @@ export function FilterEditor<T extends RuleConditionEntity>({
dispatch={dispatch}
onApply={cond => {
// @ts-expect-error - fix me
cond = unparseConditions({
...cond,
// @ts-expect-error - fix me
type: FIELD_TYPES.get(cond.field),
});
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (

View File

@@ -86,7 +86,7 @@ export const FormField = ({ style, children }: FormFieldProps) => {
// Custom inputs
type CheckboxProps = ComponentProps<'input'> & {
type CheckboxProps = Omit<ComponentProps<'input'>, 'style'> & {
style?: CSSProperties;
};

View File

@@ -34,10 +34,13 @@ export function ActionableGridListItem<T extends object>({
const hasActions = !!actions;
// Spring animation for the swipe
const [{ x }, api] = useSpring(() => ({
x: 0,
config: config.stiff,
}));
const [{ x }, api] = useSpring(
() => ({
from: { x: 0 },
config: config.stiff,
}),
[],
);
// Handle drag gestures
const bind = useDrag(
@@ -48,7 +51,7 @@ export function ActionableGridListItem<T extends object>({
if (active) {
dragStartedRef.current = true;
void api.start({
x: Math.max(-actionsWidth, Math.min(0, currentX)),
to: { x: Math.max(-actionsWidth, Math.min(0, currentX)) },
onRest: () => {
dragStartedRef.current = false;
},
@@ -62,7 +65,7 @@ export function ActionableGridListItem<T extends object>({
(vx < -0.5 && currentX < -actionsWidth / 5);
void api.start({
x: shouldReveal ? -actionsWidth : 0,
to: { x: shouldReveal ? -actionsWidth : 0 },
onRest: () => {
dragStartedRef.current = false;
setIsRevealed(shouldReveal);
@@ -141,7 +144,7 @@ export function ActionableGridListItem<T extends object>({
? actions({
close: () => {
void api.start({
x: 0,
to: { x: 0 },
onRest: () => {
setIsRevealed(false);
},

View File

@@ -52,7 +52,7 @@ export function MobileNavTabs() {
maxWidth: `${100 / COLUMN_COUNT}%`,
};
const [{ y }, api] = useSpring(() => ({ y: OPEN_DEFAULT_Y }));
const [{ y }, api] = useSpring(() => ({ from: { y: OPEN_DEFAULT_Y } }), []);
const openFull = useCallback(
({ canceled }: { canceled?: boolean }) => {
@@ -60,7 +60,7 @@ export function MobileNavTabs() {
// so we change the spring config to create a nice wobbly effect
setNavbarState('open');
void api.start({
y: OPEN_FULL_Y,
to: { y: OPEN_FULL_Y },
immediate: isTestEnv,
config: canceled ? config.wobbly : config.stiff,
});
@@ -72,7 +72,7 @@ export function MobileNavTabs() {
(velocity = 0) => {
setNavbarState('default');
void api.start({
y: OPEN_DEFAULT_Y,
to: { y: OPEN_DEFAULT_Y },
immediate: isTestEnv,
config: { ...config.stiff, velocity },
});
@@ -84,7 +84,7 @@ export function MobileNavTabs() {
(velocity = 0) => {
setNavbarState('hidden');
void api.start({
y: HIDDEN_Y,
to: { y: HIDDEN_Y },
immediate: isTestEnv,
config: { ...config.stiff, velocity },
});
@@ -199,7 +199,7 @@ export function MobileNavTabs() {
} else {
// when the user keeps dragging, we just move the sheet according to
// the cursor position
void api.start({ y: oy, immediate: true });
void api.start({ to: { y: oy }, immediate: true });
}
},
{

View File

@@ -17,8 +17,8 @@ import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import { useDeleteRuleMutation } from '@desktop-client/rules/mutations';
export function MobileRuleEditPage() {
const { t } = useTranslation();
@@ -107,8 +107,6 @@ export function MobileRuleEditPage() {
void navigate(-1);
};
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleDelete = () => {
// Runtime guard to ensure id exists
if (!id || id === 'new') {
@@ -122,17 +120,23 @@ export function MobileRuleEditPage() {
options: {
message: t('Are you sure you want to delete this rule?'),
onConfirm: async () => {
deleteRule(
{ id },
{
onSuccess: () => {
showUndoNotification({
message: t('Rule deleted successfully'),
});
void navigate('/rules');
},
},
);
try {
await send('rule-delete', id);
showUndoNotification({
message: t('Rule deleted successfully'),
});
void navigate('/rules');
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete rule. Please try again.'),
},
}),
);
}
},
},
},

View File

@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { listen } from 'loot-core/platform/client/connection';
import { listen, send } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
@@ -21,24 +21,22 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useRules } from '@desktop-client/hooks/useRules';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
import { useDeleteRuleMutation } from '@desktop-client/rules';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function MobileRulesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const [visibleRulesParam] = useUrlParam('visible-rules');
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
const {
data: allRules = [],
isLoading: isRulesLoading,
refetch: refetchRules,
} = useRules();
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
});
@@ -81,10 +79,28 @@ export function MobileRulesPage() {
);
}, [visibleRules, filter, filterData, schedules]);
const loadRules = useCallback(async () => {
try {
setIsLoading(true);
const result = await send('rules-get');
const rules = result || [];
setAllRules(rules);
} catch (error) {
console.error('Failed to load rules:', error);
setAllRules([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadRules();
}, [loadRules]);
// Listen for undo events to refresh rules list
useEffect(() => {
const onUndo = () => {
void refetchRules();
void loadRules();
};
const lastUndoEvent = undo.getUndoState('undoEvent');
@@ -93,7 +109,7 @@ export function MobileRulesPage() {
}
return listen('undo-event', onUndo);
}, [refetchRules]);
}, [loadRules]);
const handleRulePress = useCallback(
(rule: RuleEntity) => {
@@ -109,24 +125,45 @@ export function MobileRulesPage() {
[setFilter],
);
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleRuleDelete = useCallback(
(rule: RuleEntity) => {
deleteRule(
{ id: rule.id },
{
onSuccess: () => {
showUndoNotification({
message: t('Rule deleted successfully'),
});
// Refresh the rules list
void refetchRules();
},
},
);
async (rule: RuleEntity) => {
try {
const { someDeletionsFailed } = await send('rule-delete-all', [
rule.id,
]);
if (someDeletionsFailed) {
dispatch(
addNotification({
notification: {
type: 'warning',
message: t(
'This rule could not be deleted because it is linked to a schedule.',
),
},
}),
);
} else {
showUndoNotification({
message: t('Rule deleted successfully'),
});
}
// Refresh the rules list
await loadRules();
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete rule. Please try again.'),
},
}),
);
}
},
[deleteRule, showUndoNotification, t, refetchRules],
[dispatch, showUndoNotification, t, loadRules],
);
return (
@@ -162,7 +199,7 @@ export function MobileRulesPage() {
</View>
<RulesList
rules={filteredRules}
isLoading={isRulesLoading}
isLoading={isLoading}
onRulePress={handleRulePress}
onRuleDelete={handleRuleDelete}
/>

View File

@@ -12,11 +12,7 @@ import { View } from '@actual-app/components/view';
import { send, sendCatch } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import type {
RecurConfig,
RuleConditionEntity,
ScheduleEntity,
} from 'loot-core/types/models';
import type { RecurConfig, ScheduleEntity } from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';

View File

@@ -7,6 +7,7 @@ import type {
} from 'react';
import { Button } from '@actual-app/components/button';
import type { CSSProperties as EmotionCSSProperties } 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';
@@ -167,7 +168,9 @@ type FocusableAmountInputProps = Omit<AmountInputProps, 'onFocus'> & {
focused?: boolean;
disabled?: boolean;
focusedStyle?: CSSProperties;
buttonProps?: ComponentPropsWithRef<typeof Button>;
buttonProps?: Omit<ComponentPropsWithRef<typeof Button>, 'style'> & {
style?: EmotionCSSProperties;
};
onFocus?: () => void;
};

View File

@@ -75,6 +75,7 @@ import {
} from '@desktop-client/components/mobile/MobileForms';
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { createSingleTimeScheduleFromTransaction } from '@desktop-client/components/transactions/TransactionList';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -96,10 +97,6 @@ import { useSavePayeeLocationMutation } from '@desktop-client/payees';
import { locationService } from '@desktop-client/payees/location';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import {
useCreateSingleTimeScheduleFromTransaction,
useRunRulesMutation,
} from '@desktop-client/rules';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
function getFieldName(transactionId: TransactionEntity['id'], field: string) {
@@ -689,9 +686,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
[categories, isBudgetTransfer, t],
);
const { mutate: createSingleTimeScheduleFromTransaction } =
useCreateSingleTimeScheduleFromTransaction();
const onSaveInner = useCallback(() => {
const [unserializedTransaction] = unserializedTransactions;
@@ -750,24 +744,19 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
}
: unserializedTransaction;
createSingleTimeScheduleFromTransaction(
{
transaction: transactionForSchedule,
},
{
onSuccess: () => {
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
void navigate(-1);
},
},
await createSingleTimeScheduleFromTransaction(
transactionForSchedule,
);
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
void navigate(-1);
},
onCancel: onConfirmSave,
},
@@ -804,7 +793,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
unserializedTransactions,
upcomingLength,
t,
createSingleTimeScheduleFromTransaction,
]);
const onUpdateInner = useCallback(
@@ -1496,8 +1484,6 @@ function TransactionEditUnconnected({
searchParams,
]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onUpdate = useCallback(
async (
serializedTransaction: TransactionEntity,
@@ -1513,7 +1499,9 @@ function TransactionEditUnconnected({
// this on new transactions because that's how desktop works.
const newTransaction = { ...transaction };
if (isTemporary(newTransaction)) {
const afterRules = await runRulesAsync({ transaction: newTransaction });
const afterRules = await send('rules-run', {
transaction: newTransaction,
});
const diff = getChangedValues(newTransaction, afterRules);
if (diff) {
@@ -1582,7 +1570,7 @@ function TransactionEditUnconnected({
}
}
},
[dateFormat, transactions, locationAccess, runRulesAsync],
[dateFormat, transactions, locationAccess],
);
const onSave = useCallback(

View File

@@ -1,4 +1,5 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -16,16 +17,17 @@ type ManageRulesModalProps = Extract<
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
return (
<Modal name="manage-rules">
<Modal name="manage-rules" isLoading={loading}>
{({ state }) => (
<>
<ModalHeader
title={t('Rules')}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<ManageRules isModal payeeId={payeeId} />
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
</>
)}
</Modal>

View File

@@ -17,7 +17,6 @@ import { usePayees } from '@desktop-client/hooks/usePayees';
import { replaceModal } from '@desktop-client/modals/modalsSlice';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { useAddPayeeRenameRuleMutation } from '@desktop-client/rules';
const highlightStyle = { color: theme.pageTextPositive };
@@ -58,9 +57,6 @@ export function MergeUnusedPayeesModal({
allPayees.filter(p => payeeIds.includes(p.id)),
);
const { mutateAsync: addPayeeRenameRuleAsync } =
useAddPayeeRenameRuleMutation();
const onMerge = useCallback(
async (targetPayee: PayeeEntity) => {
await send('payees-merge', {
@@ -70,7 +66,7 @@ export function MergeUnusedPayeesModal({
let ruleId;
if (shouldCreateRule && !isEditingRule) {
const id = await addPayeeRenameRuleAsync({
const id = await send('rule-add-payee-rename', {
fromNames: payees.map(payee => payee.name),
to: targetPayee.id,
});
@@ -79,7 +75,7 @@ export function MergeUnusedPayeesModal({
return ruleId;
},
[shouldCreateRule, isEditingRule, payees, addPayeeRenameRuleAsync],
[shouldCreateRule, isEditingRule, payees],
);
const onMergeAndCreateRule = useCallback(

View File

@@ -1,7 +1,8 @@
import React from 'react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@actual-app/components/button';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';

View File

@@ -447,15 +447,18 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
const openY = 0;
const [mobileTransactionsOpen, setMobileTransactionsOpen] = useState(false);
const [{ y }, api] = useSpring(() => ({
y: closeY.current,
immediate: false,
}));
const [{ y }, api] = useSpring(
() => ({
from: { y: closeY.current },
immediate: false,
}),
[],
);
useEffect(() => {
closeY.current = totalHeight;
void api.start({
y: mobileTransactionsOpen ? openY : closeY.current,
to: { y: mobileTransactionsOpen ? openY : closeY.current },
immediate: false,
});
}, [totalHeight, mobileTransactionsOpen, api]);
@@ -463,7 +466,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
const open = useCallback(
({ canceled }: { canceled: boolean }) => {
void api.start({
y: openY,
to: { y: openY },
immediate: false,
config: canceled ? config.wobbly : config.stiff,
});
@@ -475,7 +478,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
const close = useCallback(
(velocity = 0) => {
void api.start({
y: closeY.current,
to: { y: closeY.current },
config: { ...config.stiff, velocity },
});
setMobileTransactionsOpen(false);
@@ -487,7 +490,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
({ offset: [, oy], cancel }) => {
if (oy < 0) {
cancel();
void api.start({ y: 0, immediate: true });
void api.start({ to: { y: 0 }, immediate: true });
return;
}
@@ -501,7 +504,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
open({ canceled: true });
setMobileTransactionsOpen(true);
} else {
void api.start({ y: oy, immediate: true });
void api.start({ to: { y: oy }, immediate: true });
}
}
},

View File

@@ -37,10 +37,8 @@ import {
isValidOp,
makeValue,
mapField,
parseActions,
parseConditions,
unparseActions,
unparseConditions,
parse,
unparse,
} from 'loot-core/shared/rules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type {
@@ -48,7 +46,6 @@ import type {
RuleActionEntity,
RuleEntity,
} from 'loot-core/types/models';
import type { WithOptional } from 'loot-core/types/util';
import { FormulaActionEditor } from './FormulaActionEditor';
@@ -66,12 +63,9 @@ import {
SelectedProvider,
useSelected,
} from '@desktop-client/hooks/useSelected';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
import {
useApplyRuleActionsMutation,
useSaveRuleMutation,
} from '@desktop-client/rules';
import { disableUndo, enableUndo } from '@desktop-client/undo';
function updateValue(array, value, update) {
@@ -964,7 +958,7 @@ function ConditionsList({
}
const getActions = splits => splits.flatMap(s => s.actions);
const getUnparsedActions = splits => getActions(splits).map(unparseActions);
const getUnparsedActions = splits => getActions(splits).map(unparse);
// TODO:
// * Dont touch child transactions?
@@ -1002,27 +996,19 @@ export function RuleEditor({
}: RuleEditorProps) {
const { t } = useTranslation();
const [conditions, setConditions] = useState(
defaultRule.conditions
.map(parseConditions)
.map(c => ({ ...c, inputKey: uuid() })),
defaultRule.conditions.map(parse).map(c => ({ ...c, inputKey: uuid() })),
);
const [actionSplits, setActionSplits] = useState<
Array<{
id: string;
actions: Array<RuleActionEntity & { inputKey: string }>;
}>
>(() => {
const parsedActions = defaultRule.actions.map(parseActions);
const [actionSplits, setActionSplits] = useState(() => {
const parsedActions = defaultRule.actions.map(parse);
return parsedActions.reduce(
(acc, action) => {
const splitIndex =
'options' in action ? (action.options?.splitIndex ?? 0) : 0;
const splitIndex = action.options?.splitIndex ?? 0;
acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
acc[splitIndex].actions.push({ ...action, inputKey: uuid() });
return acc;
},
// The pre-split group is always there
[{ id: uuid(), actions: [] } as (typeof actionSplits)[0]],
[{ id: uuid(), actions: [] }],
);
});
const [stage, setStage] = useState(defaultRule.stage);
@@ -1053,7 +1039,7 @@ export function RuleEditor({
// Run it here
async function run() {
const { filters } = await send('make-filters-from-conditions', {
conditions: conditions.map(unparseConditions),
conditions: conditions.map(unparse),
});
if (filters.length > 0) {
@@ -1225,67 +1211,74 @@ export function RuleEditor({
});
}
const { mutate: applyRuleActions } = useApplyRuleActionsMutation();
function onApply() {
const selectedTransactions = transactions.filter(({ id }) =>
selectedInst.items.has(id),
);
applyRuleActions(
{
transactions: selectedTransactions,
ruleActions: getUnparsedActions(actionSplits),
},
{
onSuccess: () => {
setActionSplits([...actionSplits]);
},
},
);
void send('rule-apply-actions', {
transactions: selectedTransactions,
actions: getUnparsedActions(actionSplits),
}).then(content => {
// This makes it refetch the transactions
content.errors.forEach(error => {
dispatch(
addNotification({
notification: {
type: 'error',
message: error,
},
}),
);
});
setActionSplits([...actionSplits]);
});
}
const { mutate: saveRule } = useSaveRuleMutation();
async function onSave() {
const rule: WithOptional<RuleEntity, 'id'> = {
const rule = {
...defaultRule,
stage,
conditionsOp,
conditions: conditions.map(unparseConditions),
conditions: conditions.map(unparse),
actions: getUnparsedActions(actionSplits),
};
saveRule(
{
rule,
},
{
onSuccess: ({ id }) => {
originalOnSave?.({
id,
...rule,
});
},
onError: error => {
if ('conditionErrors' in error && error.conditionErrors) {
setConditions(applyErrors(conditions, error.conditionErrors));
}
// @ts-expect-error fix this
const method = rule.id ? 'rule-update' : 'rule-add';
// @ts-expect-error fix this
const { error, id: newId } = await send(method, rule);
if ('actionErrors' in error && error.actionErrors) {
let usedErrorIdx = 0;
setActionSplits(
actionSplits.map(item => ({
...item,
actions: item.actions.map(action => ({
...action,
error: error.actionErrors[usedErrorIdx++] ?? null,
})),
})),
);
}
},
},
);
if (error) {
// @ts-expect-error fix this
if (error.conditionErrors) {
// @ts-expect-error fix this
setConditions(applyErrors(conditions, error.conditionErrors));
}
// @ts-expect-error fix this
if (error.actionErrors) {
let usedErrorIdx = 0;
setActionSplits(
actionSplits.map(item => ({
...item,
actions: item.actions.map(action => ({
...action,
// @ts-expect-error fix this
error: error.actionErrors[usedErrorIdx++] ?? null,
})),
})),
);
}
} else {
// If adding a rule, we got back an id
if (newId) {
// @ts-expect-error fix this
rule.id = newId;
}
// @ts-expect-error fix this
originalOnSave?.(rule);
}
}
// Enable editing existing split rules even if the feature has since been disabled.

View File

@@ -14,7 +14,7 @@ import { usePayeesById } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
type ScheduleValueProps = {
value: ScheduleEntity['id'];
value: ScheduleEntity;
};
export function ScheduleValue({ value }: ScheduleValueProps) {
@@ -35,13 +35,12 @@ export function ScheduleValue({ value }: ScheduleValueProps) {
<Value
value={value}
field="rule"
describe={val => {
const schedule = schedules.find(s => s.id === val);
if (!schedule) {
return t('(deleted)');
}
return describeSchedule(schedule, byId[schedule._payee]);
}}
data={schedules}
// TODO: this manual type coercion does not make much sense -
// should we instead do `schedule._payee.id`?
describe={schedule =>
describeSchedule(schedule, byId[schedule._payee as unknown as string])
}
/>
);
}

View File

@@ -24,6 +24,7 @@ type ValueProps<T> = {
field: unknown;
valueIsRaw?: boolean;
inline?: boolean;
data?: unknown;
describe?: (item: T) => string;
style?: CSSProperties;
};
@@ -33,7 +34,9 @@ export function Value<T>({
field,
valueIsRaw,
inline = false,
describe,
data: dataProp,
// @ts-expect-error fix this later
describe = x => x.name,
style,
}: ValueProps<T>) {
const { t } = useTranslation();
@@ -53,6 +56,32 @@ export function Value<T>({
};
const ValueText = field === 'amount' ? FinancialText : Text;
const locale = useLocale();
function getData() {
if (dataProp) {
return dataProp;
}
switch (field) {
case 'payee':
return payees;
case 'category':
return categories;
case 'category_group':
return categoryGroups;
case 'account':
return accounts;
default:
return [];
}
}
const data = getData();
const [expanded, setExpanded] = useState(false);
function onExpand(e) {
@@ -90,39 +119,23 @@ export function Value<T>({
case 'payee_name':
return value;
case 'payee':
if (valueIsRaw) {
return value;
}
const payee = payees.find(p => p.id === value);
return payee ? (describe?.(value) ?? payee.name) : t('(deleted)');
case 'category':
if (valueIsRaw) {
return value;
}
const category = categories.find(c => c.id === value);
return category
? (describe?.(value) ?? category.name)
: t('(deleted)');
case 'category_group':
if (valueIsRaw) {
return value;
}
const categoryGroup = categoryGroups.find(g => g.id === value);
return categoryGroup
? (describe?.(value) ?? categoryGroup.name)
: t('(deleted)');
case 'account':
if (valueIsRaw) {
return value;
}
const account = accounts.find(a => a.id === value);
return account ? (describe?.(value) ?? account.name) : t('(deleted)');
case 'rule':
if (valueIsRaw) {
return value;
}
if (data && Array.isArray(data)) {
const item = data.find(item => item.id === value);
if (item) {
return describe(item);
} else {
return t('(deleted)');
}
}
return describe?.(value) ?? value;
return '…';
default:
throw new Error(`Unknown field ${String(field)}`);
}

View File

@@ -179,6 +179,7 @@ export function DiscoverSchedules() {
for (const schedule of selected) {
const scheduleId = await send('schedule/create', {
conditions: schedule._conditions,
schedule: {},
});
// Now query for matching transactions and link them automatically

View File

@@ -1,18 +1,14 @@
import { t } from 'i18next';
import { extractScheduleConds } from 'loot-core/shared/schedules';
import type {
RuleConditionEntity,
RuleConditionOp,
ScheduleEntity,
} from 'loot-core/types/models';
import type { RuleConditionOp, ScheduleEntity } from 'loot-core/types/models';
import type { ScheduleFormFields } from './ScheduleEditForm';
export function updateScheduleConditions(
schedule: Partial<ScheduleEntity>,
fields: ScheduleFormFields,
): { error?: string; conditions?: RuleConditionEntity[] } {
): { error?: string; conditions?: unknown[] } {
const conds = extractScheduleConds(schedule._conditions);
const updateCond = (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import type { KeyboardEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -124,8 +124,7 @@ export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
}
inputProps={{
value: tag || '',
onInput: ({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
setTag(value.replace(/\s/g, '')),
onChange: e => setTag(e.target.value.replace(/\s/g, '')),
placeholder: t('New tag'),
ref: tagInput,
}}

View File

@@ -8,6 +8,7 @@ import { theme } from '@actual-app/components/theme';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import { getUpcomingDays } from 'loot-core/shared/schedules';
import {
addSplitTransaction,
@@ -22,6 +23,7 @@ import type {
AccountEntity,
CategoryEntity,
PayeeEntity,
RuleActionEntity,
RuleConditionEntity,
ScheduleEntity,
TransactionEntity,
@@ -39,11 +41,6 @@ import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import {
useCreateSingleTimeScheduleFromTransaction,
useRunRulesMutation,
} from '@desktop-client/rules';
// When data changes, there are two ways to update the UI:
//
// * Optimistic updates: we apply the needed updates to local data
@@ -87,6 +84,133 @@ async function saveDiffAndApply(diff, changes, onChange, learnCategories) {
);
}
export async function createSingleTimeScheduleFromTransaction(
transaction: TransactionEntity,
): Promise<ScheduleEntity['id']> {
const conditions: RuleConditionEntity[] = [
{ op: 'is', field: 'date', value: transaction.date },
];
const actions: RuleActionEntity[] = [];
const conditionFields = ['amount', 'payee', 'account'];
conditionFields.forEach(field => {
const value = transaction[field];
if (value != null && value !== '') {
conditions.push({
op: 'is',
field,
value,
} as RuleConditionEntity);
}
});
if (transaction.is_parent && transaction.subtransactions) {
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
options: {
splitIndex: 0,
},
} as RuleActionEntity);
}
transaction.subtransactions.forEach((split, index) => {
const splitIndex = index + 1;
if (split.amount != null) {
actions.push({
op: 'set-split-amount',
value: split.amount,
options: {
splitIndex,
method: 'fixed-amount',
},
} as RuleActionEntity);
}
if (split.category) {
actions.push({
op: 'set',
field: 'category',
value: split.category,
options: {
splitIndex,
},
} as RuleActionEntity);
}
if (split.notes) {
actions.push({
op: 'set',
field: 'notes',
value: split.notes,
options: {
splitIndex,
},
} as RuleActionEntity);
}
});
} else {
if (transaction.category) {
actions.push({
op: 'set',
field: 'category',
value: transaction.category,
} as RuleActionEntity);
}
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
} as RuleActionEntity);
}
}
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
const timestamp = Date.now();
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
const scheduleId = await send('schedule/create', {
conditions,
schedule: {
posts_transaction: true,
name: scheduleName,
},
});
if (actions.length > 0) {
const schedules = await send(
'query',
q('schedules').filter({ id: scheduleId }).select('rule').serialize(),
);
const ruleId = schedules?.data?.[0]?.rule;
if (ruleId) {
const rule = await send('rule-get', { id: ruleId });
if (rule) {
const linkScheduleActions = rule.actions.filter(
a => a.op === 'link-schedule',
);
await send('rule-update', {
...rule,
actions: [...linkScheduleActions, ...actions],
});
}
}
}
return scheduleId;
}
function isFutureTransaction(transaction: TransactionEntity): boolean {
const today = monthUtils.currentDay();
return transaction.date > today;
@@ -257,9 +381,6 @@ export function TransactionList({
[dispatch, onRefetch, upcomingLength, t],
);
const { mutateAsync: createSingleTimeScheduleFromTransactionAsync } =
useCreateSingleTimeScheduleFromTransaction();
const onAdd = useCallback(
async (newTransactions: TransactionEntity[]) => {
newTransactions = realizeTempTransactions(newTransactions);
@@ -282,9 +403,9 @@ export function TransactionList({
promptToConvertToSchedule(
transactionWithSubtransactions,
async () => {
await createSingleTimeScheduleFromTransactionAsync({
transaction: transactionWithSubtransactions,
});
await createSingleTimeScheduleFromTransaction(
transactionWithSubtransactions,
);
},
async () => {
await saveDiff(
@@ -299,12 +420,7 @@ export function TransactionList({
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
onRefetch();
},
[
isLearnCategoriesEnabled,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule],
);
const onSave = useCallback(
@@ -350,9 +466,7 @@ export function TransactionList({
await send('transaction-delete', { id: transaction.id });
}
await createSingleTimeScheduleFromTransactionAsync({
transaction,
});
await createSingleTimeScheduleFromTransaction(transaction);
},
saveTransaction,
);
@@ -362,13 +476,7 @@ export function TransactionList({
await saveTransaction();
},
[
isLearnCategoriesEnabled,
onChange,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule],
);
const onAddSplit = useCallback(
@@ -401,14 +509,12 @@ export function TransactionList({
[isLearnCategoriesEnabled, onChange],
);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onApplyRules = useCallback(
async (
transaction: TransactionEntity,
updatedFieldName: string | null = null,
) => {
const afterRules = await runRulesAsync({ transaction });
const afterRules = await send('rules-run', { transaction });
// Show formula errors if any
if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) {
@@ -456,7 +562,7 @@ export function TransactionList({
}
return newTransaction;
},
[dispatch, runRulesAsync],
[dispatch],
);
const onManagePayees = useCallback(

View File

@@ -1,13 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { PayeeEntity } from 'loot-core/types/models';
import { ruleQueries } from '@desktop-client/rules/queries';
export function usePayeeRules({
payeeId,
}: {
payeeId?: PayeeEntity['id'] | null;
}) {
return useQuery(ruleQueries.listPayee({ payeeId }));
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { send } from 'loot-core/platform/client/connection';
import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules';
import { ungroupTransactions } from 'loot-core/shared/transactions';
import type { IntegerAmount } from 'loot-core/shared/util';
@@ -9,8 +10,6 @@ import { useCachedSchedules } from './useCachedSchedules';
import { useSyncedPref } from './useSyncedPref';
import { calculateRunningBalancesBottomUp } from './useTransactions';
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
type UsePreviewTransactionsProps = {
filter?: (schedule: ScheduleEntity) => boolean;
options?: {
@@ -64,8 +63,6 @@ export function usePreviewTransactions({
);
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
useEffect(() => {
let isUnmounted = false;
@@ -82,7 +79,7 @@ export function usePreviewTransactions({
Promise.all(
scheduleTransactions.map(transaction =>
// Kick off an async rules application
runRulesAsync({ transaction }),
send('rules-run', { transaction }),
),
)
.then(newTrans => {
@@ -116,13 +113,7 @@ export function usePreviewTransactions({
return () => {
isUnmounted = true;
};
}, [
scheduleTransactions,
schedules,
statuses,
upcomingLength,
runRulesAsync,
]);
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
const runningBalances = useMemo(() => {
if (!options?.calculateRunningBalances) {

View File

@@ -1,15 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { UseQueryOptions } from '@tanstack/react-query';
import type { RuleEntity } from 'loot-core/types/models';
import { ruleQueries } from '@desktop-client/rules/queries';
type UseRulesOptions = Pick<UseQueryOptions<RuleEntity[]>, 'enabled'>;
export function useRules(options?: UseRulesOptions) {
return useQuery({
...ruleQueries.list(),
...(options ?? {}),
});
}

View File

@@ -1,2 +0,0 @@
export * from './queries';
export * from './mutations';

View File

@@ -1,413 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import type {
NewRuleEntity,
PayeeEntity,
RuleActionEntity,
RuleConditionEntity,
RuleEntity,
ScheduleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import { ruleQueries } from './queries';
import { useRules } from '@desktop-client/hooks/useRules';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
void queryClient.invalidateQueries({
queryKey: queryKey ?? ruleQueries.lists(),
});
}
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error?.cause ? JSON.stringify(error.cause) : error?.message,
},
}),
);
}
type AddRulePayload = {
rule: Omit<RuleEntity, 'id'>;
};
export function useAddRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ rule }: AddRulePayload) => {
return await send('rule-add', rule);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error creating rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the rule. Please try again.'),
error,
);
},
});
}
type UpdateRulePayload = {
rule: RuleEntity;
};
export function useUpdateRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ rule }: UpdateRulePayload) => {
return await send('rule-update', rule);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error updating rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error updating the rule. Please try again.'),
error,
);
},
});
}
type SaveRulePayload = {
rule: RuleEntity | NewRuleEntity;
};
export function useSaveRuleMutation() {
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
const { mutateAsync: addRuleAsync } = useAddRuleMutation();
return useMutation({
mutationFn: async ({ rule }: SaveRulePayload) => {
if ('id' in rule && rule.id) {
return await updateRuleAsync({ rule });
} else {
return await addRuleAsync({ rule });
}
},
});
}
type DeleteRulePayload = {
id: RuleEntity['id'];
};
export function useDeleteRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id }: DeleteRulePayload) => {
return await send('rule-delete', id);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error deleting rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting the rule. Please try again.'),
error,
);
},
});
}
type DeleteAllRulesPayload = {
ids: Array<RuleEntity['id']>;
};
export function useBatchDeleteRulesMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ ids }: DeleteAllRulesPayload) => {
return await send('rule-delete-all', ids);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error deleting rules:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting rules. Please try again.'),
error,
);
},
});
}
type ApplyRuleActionsPayload = {
transactions: TransactionEntity[];
ruleActions: RuleActionEntity[];
};
export function useApplyRuleActionsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({
transactions,
ruleActions,
}: ApplyRuleActionsPayload) => {
const result = await send('rule-apply-actions', {
transactions,
actions: ruleActions,
});
if (result && result.errors && result.errors.length > 0) {
throw new Error('Error applying rule actions.', {
cause: result.errors,
});
}
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error applying rule actions:', error);
dispatchErrorNotification(
dispatch,
t('There was an error applying the rule actions. Please try again.'),
error,
);
},
});
}
type AddPayeeRenameRulePayload = {
fromNames: Array<PayeeEntity['name']>;
to: PayeeEntity['id'];
};
export function useAddPayeeRenameRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ fromNames, to }: AddPayeeRenameRulePayload) => {
return await send('rule-add-payee-rename', {
fromNames,
to,
});
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error adding payee rename rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error adding the payee rename rule. Please try again.'),
error,
);
},
});
}
type RunRulesPayload = {
transaction: TransactionEntity;
};
export function useRunRulesMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ transaction }: RunRulesPayload) => {
return await send('rules-run', { transaction });
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error running rules for transaction:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error running the rules for transaction. Please try again.',
),
error,
);
},
});
}
// TODO: Move to schedules mutations file once we have schedule-related mutations
export function useCreateSingleTimeScheduleFromTransaction() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: allRules = [] } = useRules();
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
return useMutation({
mutationFn: async ({
transaction,
}: {
transaction: TransactionEntity;
}): Promise<ScheduleEntity['id']> => {
const conditions: RuleConditionEntity[] = [
{ op: 'is', field: 'date', value: transaction.date },
];
const actions: RuleActionEntity[] = [];
const conditionFields = ['amount', 'payee', 'account'] as const;
conditionFields.forEach(field => {
const value = transaction[field];
if (value != null && value !== '') {
conditions.push({
op: 'is',
field,
value,
} as RuleConditionEntity);
}
});
if (transaction.is_parent && transaction.subtransactions) {
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
options: {
splitIndex: 0,
},
} as RuleActionEntity);
}
transaction.subtransactions.forEach((split, index) => {
const splitIndex = index + 1;
if (split.amount != null) {
actions.push({
op: 'set-split-amount',
value: split.amount,
options: {
splitIndex,
method: 'fixed-amount',
},
} as RuleActionEntity);
}
if (split.category) {
actions.push({
op: 'set',
field: 'category',
value: split.category,
options: {
splitIndex,
},
} as RuleActionEntity);
}
if (split.notes) {
actions.push({
op: 'set',
field: 'notes',
value: split.notes,
options: {
splitIndex,
},
} as RuleActionEntity);
}
});
} else {
if (transaction.category) {
actions.push({
op: 'set',
field: 'category',
value: transaction.category,
} as RuleActionEntity);
}
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
} as RuleActionEntity);
}
}
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
const timestamp = Date.now();
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
const scheduleId = await send('schedule/create', {
conditions,
schedule: {
posts_transaction: true,
name: scheduleName,
},
});
if (actions.length > 0) {
const schedules = await aqlQuery(
q('schedules').filter({ id: scheduleId }).select('rule'),
);
const ruleId = schedules?.data?.[0]?.rule;
if (ruleId) {
const rule = allRules.find(r => r.id === ruleId);
if (rule) {
const linkScheduleActions = rule.actions.filter(
a => a.op === 'link-schedule',
);
await updateRuleAsync({
rule: {
...rule,
actions: [...linkScheduleActions, ...actions],
},
});
}
}
}
return scheduleId;
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error creating schedule from transaction:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error creating the schedule from the transaction. Please try again.',
),
error,
);
},
});
}

View File

@@ -1,31 +0,0 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import type { PayeeEntity, RuleEntity } from 'loot-core/types/models';
export const ruleQueries = {
all: () => ['rules'] as const,
lists: () => [...ruleQueries.all(), 'lists'] as const,
list: () =>
queryOptions<RuleEntity[]>({
queryKey: [...ruleQueries.lists()],
queryFn: async () => {
return await send('rules-get');
},
staleTime: Infinity,
}),
listPayee: ({ payeeId }: { payeeId?: PayeeEntity['id'] | null }) =>
queryOptions<RuleEntity[]>({
queryKey: [...ruleQueries.lists(), { payeeId }] as const,
queryFn: async () => {
if (!payeeId) {
// Should never happen since the query is disabled when payeeId is not provided,
// but is needed to satisfy TypeScript.
throw new Error('payeeId is required.');
}
return await send('payees-get-rules', { id: payeeId });
},
staleTime: Infinity,
enabled: !!payeeId,
}),
};

View File

@@ -0,0 +1,897 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { createCoordinator } from './shared-browser-server-core';
// ── Types ───────────────────────────────────────────────────────────────
type MockPort = {
postMessage: Mock;
start: Mock;
onmessage: ((event: { data: unknown }) => void) | null;
};
type Coordinator = ReturnType<typeof createCoordinator>;
// ── Test helpers ────────────────────────────────────────────────────────
function createMockPort(): MockPort {
return { postMessage: vi.fn(), start: vi.fn(), onmessage: null };
}
function setup(): Coordinator {
return createCoordinator();
}
/** Simulate a new tab connecting to the SharedWorker. */
function connectTab(coordinator: Coordinator): MockPort {
const port = createMockPort();
coordinator.onconnect({ ports: [port] });
return port;
}
/** Send a message on behalf of a port (as if the tab sent it). */
function sendMsg(port: MockPort, msg: Record<string, unknown>): void {
port.onmessage!({ data: msg });
}
/** Send the standard init message from a tab. */
function sendInit(port: MockPort): void {
sendMsg(port, { type: 'init', version: '1.0', isDev: false });
}
/**
* Simulate the leader's Worker reporting that the backend is connected.
* In the real flow the Worker sends a 'connect' message → the bridge
* wraps it in __from-worker → the SharedWorker broadcasts it.
*/
function simulateWorkerConnect(leaderPort: MockPort): void {
sendMsg(leaderPort, {
type: '__from-worker',
msg: { type: 'connect' },
});
}
/**
* Set up a fully running budget group with one leader tab.
* Returns the leader port.
*/
function setupBudgetGroup(
coordinator: Coordinator,
budgetId: string,
): MockPort {
const leader = connectTab(coordinator);
sendInit(leader);
leader.postMessage.mockClear();
// Lobby leader → load budget → migrates lobby to real budget
sendMsg(leader, {
id: 'lb-1',
name: 'load-budget',
args: { id: budgetId },
});
// Simulate the Worker reporting connect
simulateWorkerConnect(leader);
// Simulate successful load-budget reply
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'reply', id: 'lb-1', result: {} },
});
leader.postMessage.mockClear();
return leader;
}
// ── Tests ───────────────────────────────────────────────────────────────
describe('SharedWorker coordinator', () => {
let coordinator: Coordinator;
beforeEach(() => {
vi.useFakeTimers();
coordinator = setup();
});
afterEach(() => {
coordinator.destroy();
vi.useRealTimers();
});
// ── Initialization ──────────────────────────────────────────────────
describe('initialization', () => {
it('first tab is elected as lobby leader', () => {
const port = connectTab(coordinator);
sendInit(port);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: '__lobby',
}),
);
});
it('second tab with no connected backend gets UNASSIGNED role', () => {
const port1 = connectTab(coordinator);
sendInit(port1);
const port2 = connectTab(coordinator);
sendInit(port2);
// Second tab should be UNASSIGNED (backend is booting, not connected)
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'UNASSIGNED',
}),
);
});
it('second tab gets connect message when backend is already running', () => {
setupBudgetGroup(coordinator, 'budget-1');
const port2 = connectTab(coordinator);
sendInit(port2);
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: '__role-change', role: 'UNASSIGNED' }),
);
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
it('caches init message for new leaders', () => {
const port1 = connectTab(coordinator);
const initMsg = { type: 'init', version: '2.0', isDev: true };
sendMsg(port1, initMsg);
expect(coordinator.getState().cachedInitMsg).toEqual(initMsg);
});
it('sends cached init failure to late-joining tabs', () => {
// Set up a leader whose Worker reports init failure
const leader = connectTab(coordinator);
sendInit(leader);
simulateWorkerConnect(leader);
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'app-init-failure', error: 'boom' },
});
const port2 = connectTab(coordinator);
sendInit(port2);
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'app-init-failure', error: 'boom' }),
);
});
});
// ── Load budget ─────────────────────────────────────────────────────
describe('load-budget', () => {
it('lobby leader migrates to real budget group on load-budget', () => {
const leader = connectTab(coordinator);
sendInit(leader);
leader.postMessage.mockClear();
sendMsg(leader, {
id: 'lb-1',
name: 'load-budget',
args: { id: 'my-budget' },
});
const state = coordinator.getState();
expect(state.budgetGroups.has('__lobby')).toBe(false);
expect(state.budgetGroups.has('my-budget')).toBe(true);
expect(state.portToBudget.get(leader)).toBe('my-budget');
// Should have forwarded load-budget to the Worker
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'load-budget' }),
}),
);
});
it('second tab joins existing budget as follower', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
follower.postMessage.mockClear();
sendMsg(follower, {
id: 'lb-2',
name: 'load-budget',
args: { id: 'budget-1' },
});
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'FOLLOWER',
budgetId: 'budget-1',
}),
);
// Should also get connect since backend is already running
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.has(follower)).toBe(true);
});
it('new tab on unloaded budget becomes leader for that budget', () => {
// Set up budget-1 so the lobby is consumed
setupBudgetGroup(coordinator, 'budget-1');
const tab2 = connectTab(coordinator);
sendInit(tab2);
tab2.postMessage.mockClear();
sendMsg(tab2, {
id: 'lb-3',
name: 'load-budget',
args: { id: 'budget-2' },
});
expect(tab2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-2',
}),
);
const state = coordinator.getState();
expect(state.budgetGroups.has('budget-2')).toBe(true);
expect(state.budgetGroups.get('budget-2').leaderPort).toBe(tab2);
});
it('leader switching budgets pushes followers off old budget', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
// Add a follower
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
// Leader loads a different budget
sendMsg(leader, {
id: 'lb-switch',
name: 'load-budget',
args: { id: 'budget-2' },
});
// Follower should be pushed to show-budgets
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
});
});
// ── Close budget ────────────────────────────────────────────────────
describe('close-budget', () => {
it('follower gets synthetic reply and leaves group', () => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(follower, { id: 'cb-1', name: 'close-budget' });
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'reply', id: 'cb-1', data: {} }),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.has(follower)).toBe(false);
expect(coordinator.getState().unassignedPorts.has(follower)).toBe(true);
});
it('leader with followers transfers leadership', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
leader.postMessage.mockClear();
sendMsg(leader, { id: 'cb-leader', name: 'close-budget' });
// Leader should get __close-and-transfer
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: 'cb-leader',
}),
);
// Follower should be promoted to leader
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-1',
}),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.leaderPort).toBe(follower);
});
it('leader with no followers forwards close to Worker', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
leader.postMessage.mockClear();
sendMsg(leader, { id: 'cb-solo', name: 'close-budget' });
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'close-budget' }),
}),
);
});
it('close-budget reply from Worker cleans up group', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
sendMsg(leader, { id: 'cb-solo', name: 'close-budget' });
// Simulate Worker reply
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'reply', id: 'cb-solo', result: {} },
});
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
});
// ── Tab disconnection & failover ────────────────────────────────────
describe('tab disconnection', () => {
it('leader disconnect promotes follower', () => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
// Find current leader to disconnect it
const group = coordinator.getState().budgetGroups.get('budget-1');
const leader = group.leaderPort as MockPort;
// Leader closes tab
sendMsg(leader, { type: 'tab-closing' });
// Follower should be promoted
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-1',
}),
);
const updatedGroup = coordinator.getState().budgetGroups.get('budget-1');
expect(updatedGroup.leaderPort).toBe(follower);
});
it('last tab leaving removes budget group', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
sendMsg(leader, { type: 'tab-closing' });
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
it('follower disconnect cleans up group membership', () => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.has(follower)).toBe(true);
sendMsg(follower, { type: 'tab-closing' });
expect(group.followers.has(follower)).toBe(false);
});
});
// ── Heartbeat ───────────────────────────────────────────────────────
describe('heartbeat', () => {
it('sends heartbeat pings to all connected ports', () => {
const port1 = connectTab(coordinator);
sendInit(port1);
port1.postMessage.mockClear();
vi.advanceTimersByTime(10_000);
expect(port1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: '__heartbeat-ping' }),
);
});
it('removes ports that do not respond to heartbeat', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
// First heartbeat — marks port as pending
vi.advanceTimersByTime(10_000);
// Second heartbeat — port didn't respond, gets removed
vi.advanceTimersByTime(10_000);
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(
false,
);
});
it('keeps ports that respond with pong', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
// First heartbeat
vi.advanceTimersByTime(10_000);
// Respond with pong
sendMsg(leader, { type: '__heartbeat-pong' });
// Second heartbeat — should NOT remove the port
vi.advanceTimersByTime(10_000);
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(true);
});
});
// ── Worker message routing ──────────────────────────────────────────
describe('Worker message routing', () => {
it('routes reply to the port that sent the request', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
// Follower sends a request
follower.postMessage.mockClear();
sendMsg(follower, { id: 'req-1', name: 'get-budgets' });
// Worker replies
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'reply', id: 'req-1', result: ['b1'] },
});
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'reply',
id: 'req-1',
result: ['b1'],
}),
);
});
it('broadcasts connect to entire group and unassigned ports', () => {
const leader = connectTab(coordinator);
sendInit(leader);
// Load budget (still in lobby migration)
sendMsg(leader, {
id: 'lb-1',
name: 'load-budget',
args: { id: 'budget-1' },
});
const unassigned = connectTab(coordinator);
sendInit(unassigned);
unassigned.postMessage.mockClear();
// Worker reports connected
simulateWorkerConnect(leader);
expect(unassigned.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
it('forwards fire-and-forget messages (no id) to Worker', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
leader.postMessage.mockClear();
// Fire-and-forget message (no id)
sendMsg(follower, { type: 'client-connected-to-backend' });
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({
type: 'client-connected-to-backend',
}),
}),
);
});
it('unassigned ports route to any connected group', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const unassigned = connectTab(coordinator);
sendInit(unassigned);
leader.postMessage.mockClear();
sendMsg(unassigned, { id: 'req-u', name: 'get-budgets' });
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'get-budgets' }),
}),
);
});
});
// ── Budget-replacing operations ─────────────────────────────────────
describe('budget-replacing operations', () => {
it.each(['create-budget', 'import-budget', 'duplicate-budget'])(
'%s from follower gets own temporary Worker',
(opName: string) => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(follower, { id: 'op-1', name: opName });
// Follower should be elected as leader for a temp group
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
}),
);
// The temp group should exist
const state = coordinator.getState();
const tempBudget = state.portToBudget.get(follower);
expect(tempBudget).toMatch(/^__creating-/);
},
);
it('create-budget from leader pushes followers off', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(leader, { id: 'cb-1', name: 'create-budget' });
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.size).toBe(0);
});
it('create-demo-budget evicts existing _demo-budget group', () => {
const demoLeader = setupBudgetGroup(coordinator, '_demo-budget');
const tab2 = connectTab(coordinator);
sendInit(tab2);
tab2.postMessage.mockClear();
sendMsg(tab2, { id: 'cdb-1', name: 'create-demo-budget' });
// Old demo leader should have been evicted
expect(demoLeader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: null,
}),
);
expect(demoLeader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(coordinator.getState().budgetGroups.has('_demo-budget')).toBe(
false,
);
});
it('create-budget with testMode evicts existing _test-budget group', () => {
const testLeader = setupBudgetGroup(coordinator, '_test-budget');
const tab2 = connectTab(coordinator);
sendInit(tab2);
tab2.postMessage.mockClear();
sendMsg(tab2, {
id: 'ctb-1',
name: 'create-budget',
args: { testMode: true },
});
expect(testLeader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: null,
}),
);
});
it('load-prefs reply renames __creating- temp group to real budget ID', () => {
setupBudgetGroup(coordinator, 'budget-1');
const creator = connectTab(coordinator);
sendInit(creator);
sendMsg(creator, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
// Creator sends create-budget → gets temp Worker
sendMsg(creator, { id: 'cb-1', name: 'create-budget' });
const tempId = coordinator.getState().portToBudget.get(creator);
expect(tempId).toMatch(/^__creating-/);
// Simulate backend connect for the temp group
simulateWorkerConnect(creator);
// Track a load-prefs request
sendMsg(creator, { id: 'lp-1', name: 'load-prefs' });
// Worker replies with load-prefs containing the new budget ID
sendMsg(creator, {
type: '__from-worker',
msg: { type: 'reply', id: 'lp-1', result: { id: 'new-budget-123' } },
});
const state = coordinator.getState();
expect(state.budgetGroups.has(tempId)).toBe(false);
expect(state.budgetGroups.has('new-budget-123')).toBe(true);
expect(state.portToBudget.get(creator)).toBe('new-budget-123');
});
});
// ── Delete budget ───────────────────────────────────────────────────
describe('delete-budget', () => {
it('evicts the group running the deleted budget', () => {
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
leader1.postMessage.mockClear();
// Tab on budget-2 deletes budget-1
sendMsg(leader2, {
id: 'db-1',
name: 'delete-budget',
args: { id: 'budget-1' },
});
// budget-1 leader should be evicted
expect(leader1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: null,
}),
);
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
it('spins up temp Worker when no connected group remains after eviction', () => {
setupBudgetGroup(coordinator, 'budget-1');
// A new unassigned tab tries to delete budget-1
const deleter = connectTab(coordinator);
sendInit(deleter);
deleter.postMessage.mockClear();
sendMsg(deleter, {
id: 'db-1',
name: 'delete-budget',
args: { id: 'budget-1' },
});
// After evicting budget-1, no connected group remains
// → deleter should get a temp Worker
const tempId = coordinator.getState().portToBudget.get(deleter);
expect(tempId).toMatch(/^__deleting-/);
expect(deleter.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
}),
);
});
});
// ── Track restore ───────────────────────────────────────────────────
describe('__track-restore', () => {
it('registers a budget restore for reply routing', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
sendMsg(leader, {
type: '__track-restore',
requestId: 'restore-1',
budgetId: 'budget-1',
});
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.requestToPort.get('restore-1')).toBe(leader);
expect(group.requestNames.get('restore-1')).toBe('load-budget');
expect(group.requestBudgetIds.get('restore-1')).toBe('budget-1');
});
});
// ── Multiple budgets ────────────────────────────────────────────────
describe('multiple budgets', () => {
it('supports multiple independent budget groups', () => {
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
const state = coordinator.getState();
expect(state.budgetGroups.size).toBe(2);
expect(state.budgetGroups.get('budget-1').leaderPort).toBe(leader1);
expect(state.budgetGroups.get('budget-2').leaderPort).toBe(leader2);
});
it('requests from one group do not leak into another', () => {
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
// Follower joins budget-1
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
leader2.postMessage.mockClear();
// Follower sends request — should go to budget-1's leader only
sendMsg(follower, { id: 'req-1', name: 'some-action' });
expect(leader1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: '__to-worker' }),
);
// leader2 should NOT have received this
expect(leader2.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: '__to-worker' }),
);
});
});
// ── Eviction ────────────────────────────────────────────────────────
describe('evictGroup', () => {
it('evicts followers and leader, sending them to show-budgets', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const f1 = connectTab(coordinator);
sendInit(f1);
sendMsg(f1, {
id: 'lb-f1',
name: 'load-budget',
args: { id: 'budget-1' },
});
const f2 = connectTab(coordinator);
sendInit(f2);
sendMsg(f2, {
id: 'lb-f2',
name: 'load-budget',
args: { id: 'budget-1' },
});
f1.postMessage.mockClear();
f2.postMessage.mockClear();
leader.postMessage.mockClear();
// Another tab triggers deletion of budget-1
const deleter = connectTab(coordinator);
sendInit(deleter);
// Set up a second budget so the deleter has a Worker
sendMsg(deleter, {
id: 'lb-del',
name: 'load-budget',
args: { id: 'budget-other' },
});
simulateWorkerConnect(deleter);
sendMsg(deleter, {
type: '__from-worker',
msg: { type: 'reply', id: 'lb-del', result: {} },
});
sendMsg(deleter, {
id: 'db-1',
name: 'delete-budget',
args: { id: 'budget-1' },
});
// All budget-1 tabs should be pushed to show-budgets
expect(f1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(f2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
});
});

View File

@@ -0,0 +1,738 @@
// Core coordinator logic for multi-tab, multi-budget SharedWorker support.
//
// This module exports a factory so it can be tested independently.
// The SharedWorker entry point (shared-browser-server.js) calls
// createCoordinator() and wires the result to self.onconnect.
// ── Types ────────────────────────────────────────────────────────────────
type ConsoleLevel = 'log' | 'warn' | 'error' | 'info';
/** Minimal port interface (subset of MessagePort used by the coordinator). */
export type CoordinatorPort = {
postMessage(msg: unknown): void;
start(): void;
onmessage: ((event: { data: Record<string, unknown> }) => void) | null;
};
type BudgetGroup = {
leaderPort: CoordinatorPort;
followers: Set<CoordinatorPort>;
backendConnected: boolean;
requestToPort: Map<string, CoordinatorPort>;
requestNames: Map<string, string>;
requestBudgetIds: Map<string, string>;
};
type CoordinatorOptions = {
enableConsoleForwarding?: boolean;
};
// ── Factory ──────────────────────────────────────────────────────────────
export function createCoordinator({
enableConsoleForwarding = false,
}: CoordinatorOptions = {}) {
// ── State ──────────────────────────────────────────────────────────────
const connectedPorts: CoordinatorPort[] = [];
let cachedInitMsg: Record<string, unknown> | null = null;
let lastAppInitFailure: Record<string, unknown> | null = null;
const pendingPongs = new Set<CoordinatorPort>();
const budgetGroups = new Map<string, BudgetGroup>();
const portToBudget = new Map<CoordinatorPort, string>();
const unassignedPorts = new Set<CoordinatorPort>();
// ── Console forwarding ─────────────────────────────────────────────────
if (enableConsoleForwarding) {
const _originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
info: console.info.bind(console),
};
function forwardConsole(level: ConsoleLevel, args: unknown[]) {
_originalConsole[level](...args);
const serialized = args.map(a => {
if (a instanceof Error) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
return String(a);
});
for (const port of connectedPorts) {
port.postMessage({
type: '__shared-worker-console',
level,
args: serialized,
});
}
}
console.log = (...args: unknown[]) => forwardConsole('log', args);
console.warn = (...args: unknown[]) => forwardConsole('warn', args);
console.error = (...args: unknown[]) => forwardConsole('error', args);
console.info = (...args: unknown[]) => forwardConsole('info', args);
}
// ── Helpers ────────────────────────────────────────────────────────────
function createBudgetGroup(leaderPort: CoordinatorPort): BudgetGroup {
return {
leaderPort,
followers: new Set(),
backendConnected: false,
requestToPort: new Map(),
requestNames: new Map(),
requestBudgetIds: new Map(),
};
}
function logState(action: string) {
const groups: string[] = [];
for (const [bid, g] of budgetGroups) {
groups.push(`"${bid}": leader + ${g.followers.size} follower(s)`);
}
console.log(
`[SharedWorker] ${action}${connectedPorts.length} tab(s), ${unassignedPorts.size} unassigned, groups: [${groups.join(', ') || 'none'}]`,
);
}
function broadcastToGroup(
budgetId: string,
msg: unknown,
excludePort: CoordinatorPort | null,
) {
const group = budgetGroups.get(budgetId);
if (!group) return;
if (group.leaderPort !== excludePort) {
group.leaderPort.postMessage(msg);
}
for (const p of group.followers) {
if (p !== excludePort) {
p.postMessage(msg);
}
}
}
function broadcastToAllInGroup(budgetId: string, msg: unknown) {
broadcastToGroup(budgetId, msg, null);
}
// ── Heartbeat ──────────────────────────────────────────────────────────
const heartbeatId = setInterval(() => {
for (const port of [...pendingPongs]) {
pendingPongs.delete(port);
removePort(port);
}
for (const port of connectedPorts) {
pendingPongs.add(port);
port.postMessage({ type: '__heartbeat-ping' });
}
}, 10_000);
// ── Port removal & leader failover ────────────────────────────────────
function removePort(port: CoordinatorPort) {
const idx = connectedPorts.indexOf(port);
if (idx !== -1) connectedPorts.splice(idx, 1);
unassignedPorts.delete(port);
const budgetId = portToBudget.get(port);
portToBudget.delete(port);
if (!budgetId) return;
const group = budgetGroups.get(budgetId);
if (!group) return;
if (port === group.leaderPort) {
if (group.followers.size > 0) {
const candidate = group.followers.values().next()
.value as CoordinatorPort;
group.followers.delete(candidate);
console.log(
`[SharedWorker] Leader left budget "${budgetId}" — promoting follower`,
);
electLeader(budgetId, candidate, budgetId);
} else {
console.log(
`[SharedWorker] Last tab left budget "${budgetId}" — removing group`,
);
budgetGroups.delete(budgetId);
}
} else {
group.followers.delete(port);
for (const [id, p] of group.requestToPort) {
if (p === port) {
group.requestToPort.delete(id);
group.requestNames.delete(id);
}
}
}
}
// ── Leader election ───────────────────────────────────────────────────
function electLeader(
budgetId: string,
port: CoordinatorPort,
budgetToRestore?: string | null,
pendingMsg?: Record<string, unknown> | null,
) {
let group = budgetGroups.get(budgetId);
if (!group) {
group = createBudgetGroup(port);
budgetGroups.set(budgetId, group);
} else {
group.leaderPort = port;
group.backendConnected = false;
group.requestToPort.clear();
group.requestNames.clear();
group.requestBudgetIds.clear();
}
const prevBudget = portToBudget.get(port);
if (prevBudget && prevBudget !== budgetId) {
removePortFromGroup(port, prevBudget);
}
portToBudget.set(port, budgetId);
unassignedPorts.delete(port);
console.log(
`[SharedWorker] Elected leader for "${budgetId}" (${group.followers.size} follower(s))`,
);
port.postMessage({
type: '__role-change',
role: 'LEADER',
budgetId,
});
if (cachedInitMsg) {
port.postMessage({
type: '__become-leader',
initMsg: cachedInitMsg,
budgetToRestore: budgetToRestore || null,
pendingMsg: pendingMsg || null,
});
}
}
function addFollower(budgetId: string, port: CoordinatorPort) {
const group = budgetGroups.get(budgetId);
if (!group) return;
const prevBudget = portToBudget.get(port);
if (prevBudget && prevBudget !== budgetId) {
removePortFromGroup(port, prevBudget);
}
group.followers.add(port);
portToBudget.set(port, budgetId);
unassignedPorts.delete(port);
port.postMessage({
type: '__role-change',
role: 'FOLLOWER',
budgetId,
});
if (group.backendConnected) {
port.postMessage({ type: 'connect' });
}
}
function removePortFromGroup(port: CoordinatorPort, budgetId: string) {
const group = budgetGroups.get(budgetId);
if (!group) return;
group.followers.delete(port);
for (const [id, p] of group.requestToPort) {
if (p === port) {
group.requestToPort.delete(id);
group.requestNames.delete(id);
}
}
}
function evictGroup(budgetId: string, excludePort: CoordinatorPort) {
const group = budgetGroups.get(budgetId);
if (!group) return;
const evicted: CoordinatorPort[] = [];
for (const p of group.followers) {
if (p !== excludePort) {
p.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(p);
unassignedPorts.add(p);
evicted.push(p);
}
}
group.followers.clear();
if (group.leaderPort && group.leaderPort !== excludePort) {
group.leaderPort.postMessage({
type: '__close-and-transfer',
requestId: null,
});
group.leaderPort.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(group.leaderPort);
unassignedPorts.add(group.leaderPort);
evicted.push(group.leaderPort);
}
budgetGroups.delete(budgetId);
if (evicted.length > 0) {
console.log(
`[SharedWorker] Evicted ${evicted.length} tab(s) from budget "${budgetId}"`,
);
}
}
// ── Budget lifecycle helpers ──────────────────────────────────────────
function handleBudgetLoaded(
leaderPort: CoordinatorPort,
oldGroupId: string,
newBudgetId: string,
) {
const oldGroup = budgetGroups.get(oldGroupId);
if (!oldGroup) return;
if (oldGroupId !== newBudgetId) {
const existingTarget = budgetGroups.get(newBudgetId);
if (existingTarget && existingTarget !== oldGroup) {
console.warn(
`[SharedWorker] handleBudgetLoaded: conflict — group "${newBudgetId}" already exists`,
);
return;
}
budgetGroups.delete(oldGroupId);
budgetGroups.set(newBudgetId, oldGroup);
portToBudget.set(leaderPort, newBudgetId);
for (const p of oldGroup.followers) {
portToBudget.set(p, newBudgetId);
}
console.log(
`[SharedWorker] Budget loaded: "${newBudgetId}" (leader + ${oldGroup.followers.size} follower(s))`,
);
}
logState(`Budget "${newBudgetId}" ready`);
}
function handleBudgetClosed(closingPort: CoordinatorPort, budgetId: string) {
const group = budgetGroups.get(budgetId);
if (!group) return;
if (closingPort === group.leaderPort && group.followers.size === 0) {
budgetGroups.delete(budgetId);
portToBudget.delete(closingPort);
unassignedPorts.add(closingPort);
logState(`Budget "${budgetId}" closed (no tabs remain)`);
}
}
function migrateLobbyLeader(
port: CoordinatorPort,
budgetId: string,
pendingMsg: Record<string, unknown>,
) {
const lobbyGroup = budgetGroups.get('__lobby');
if (lobbyGroup && port === lobbyGroup.leaderPort) {
budgetGroups.delete('__lobby');
budgetGroups.set(budgetId, lobbyGroup);
portToBudget.set(port, budgetId);
lobbyGroup.requestToPort.set(pendingMsg.id as string, port);
lobbyGroup.requestNames.set(
pendingMsg.id as string,
pendingMsg.name as string,
);
lobbyGroup.requestBudgetIds.set(pendingMsg.id as string, budgetId);
lobbyGroup.leaderPort.postMessage({
type: '__to-worker',
msg: pendingMsg,
});
port.postMessage({
type: '__role-change',
role: 'LEADER',
budgetId,
});
logState(`Lobby leader now on budget "${budgetId}"`);
}
}
// ── Connection handler ────────────────────────────────────────────────
function onconnect(e: { ports: CoordinatorPort[] }) {
const port = e.ports[0];
connectedPorts.push(port);
unassignedPorts.add(port);
logState('Tab connected');
port.onmessage = function (event: { data: Record<string, unknown> }) {
try {
const msg = event.data;
const portBudget = portToBudget.get(port);
const group = portBudget ? budgetGroups.get(portBudget) : null;
// ── Tab lifecycle ──────────────────────────────────────────
if (msg.type === 'tab-closing') {
pendingPongs.delete(port);
removePort(port);
logState('Tab closed');
return;
}
if (msg.type === '__heartbeat-pong') {
pendingPongs.delete(port);
return;
}
// ── Initialization ─────────────────────────────────────────
if (msg.type === 'init') {
cachedInitMsg = msg;
if (lastAppInitFailure) {
port.postMessage(lastAppInitFailure);
} else {
let anyConnected = false;
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
anyConnected = true;
break;
}
}
if (anyConnected) {
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
port.postMessage({ type: 'connect' });
} else if (budgetGroups.size > 0) {
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
} else {
electLeader('__lobby', port);
}
}
return;
}
// ── Leader tab forwarding Worker messages back ─────────────
if (msg.type === '__from-worker') {
if (!group || port !== group.leaderPort) return;
const workerMsg = msg.msg as Record<string, unknown>;
if (workerMsg.type === 'reply' || workerMsg.type === 'error') {
const targetPort = group.requestToPort.get(workerMsg.id as string);
if (targetPort) {
targetPort.postMessage(workerMsg);
const name = group.requestNames.get(workerMsg.id as string);
if (workerMsg.type === 'reply' && name === 'load-budget') {
const budgetId = group.requestBudgetIds.get(
workerMsg.id as string,
);
if (budgetId) {
group.requestBudgetIds.delete(workerMsg.id as string);
handleBudgetLoaded(port, portBudget!, budgetId);
}
}
if (workerMsg.type === 'reply' && name === 'close-budget') {
handleBudgetClosed(targetPort, portBudget!);
}
if (
workerMsg.type === 'reply' &&
name === 'load-prefs' &&
portBudget &&
portBudget.startsWith('__creating-') &&
workerMsg.result &&
(workerMsg.result as Record<string, unknown>).id
) {
handleBudgetLoaded(
port,
portBudget,
(workerMsg.result as Record<string, unknown>).id as string,
);
}
group.requestToPort.delete(workerMsg.id as string);
group.requestNames.delete(workerMsg.id as string);
}
} else if (workerMsg.type === 'connect') {
group.backendConnected = true;
broadcastToAllInGroup(portBudget!, workerMsg);
for (const p of unassignedPorts) {
p.postMessage(workerMsg);
}
} else if (workerMsg.type === 'app-init-failure') {
lastAppInitFailure = workerMsg;
broadcastToAllInGroup(portBudget!, workerMsg);
} else {
broadcastToAllInGroup(portBudget!, workerMsg);
}
return;
}
// ── Leader tab registering a budget restore ────────────────
if (msg.type === '__track-restore') {
if (group) {
group.requestToPort.set(msg.requestId as string, port);
group.requestNames.set(msg.requestId as string, 'load-budget');
group.requestBudgetIds.set(
msg.requestId as string,
msg.budgetId as string,
);
}
return;
}
// ── Request interception & routing ─────────────────────────
if (
msg.name === 'load-budget' &&
msg.args &&
(msg.args as Record<string, unknown>).id
) {
const budgetId = (msg.args as Record<string, unknown>).id as string;
const existingGroup = budgetGroups.get(budgetId);
if (existingGroup && existingGroup.backendConnected) {
addFollower(budgetId, port);
existingGroup.requestToPort.set(msg.id as string, port);
existingGroup.requestNames.set(
msg.id as string,
msg.name as string,
);
existingGroup.requestBudgetIds.set(msg.id as string, budgetId);
existingGroup.leaderPort.postMessage({
type: '__to-worker',
msg,
});
logState(`Tab joined budget "${budgetId}" as follower`);
return;
}
if (existingGroup && !existingGroup.backendConnected) {
addFollower(budgetId, port);
existingGroup.requestToPort.set(msg.id as string, port);
existingGroup.requestNames.set(
msg.id as string,
msg.name as string,
);
existingGroup.requestBudgetIds.set(msg.id as string, budgetId);
existingGroup.leaderPort.postMessage({
type: '__to-worker',
msg,
});
logState(
`Tab joined budget "${budgetId}" as follower (backend booting)`,
);
return;
}
if (portBudget === '__lobby') {
migrateLobbyLeader(port, budgetId, msg);
} else if (group && port === group.leaderPort) {
for (const p of group.followers) {
p.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(p);
unassignedPorts.add(p);
}
if (group.followers.size > 0) {
console.log(
`[SharedWorker] Leader switching budgets — pushed ${group.followers.size} follower(s) off "${portBudget}"`,
);
group.followers.clear();
}
group.requestToPort.set(msg.id as string, port);
group.requestNames.set(msg.id as string, msg.name as string);
group.requestBudgetIds.set(msg.id as string, budgetId);
group.leaderPort.postMessage({ type: '__to-worker', msg });
} else {
electLeader(budgetId, port, null, msg);
const newGroup = budgetGroups.get(budgetId);
if (newGroup) {
newGroup.requestToPort.set(msg.id as string, port);
newGroup.requestNames.set(msg.id as string, msg.name as string);
newGroup.requestBudgetIds.set(msg.id as string, budgetId);
}
logState(`Tab became leader for new budget "${budgetId}"`);
}
return;
}
// close-budget: handle leader vs follower
if (msg.name === 'close-budget' && group) {
if (port === group.leaderPort) {
if (group.followers.size > 0) {
const newLeader = group.followers.values().next()
.value as CoordinatorPort;
group.followers.delete(newLeader);
console.log(
`[SharedWorker] Leader closing budget "${portBudget}" but ${group.followers.size + 1} tab(s) remain — transferring`,
);
port.postMessage({
type: '__close-and-transfer',
requestId: msg.id,
});
electLeader(portBudget!, newLeader, portBudget);
portToBudget.delete(port);
unassignedPorts.add(port);
logState(`Leadership transferred for "${portBudget}"`);
return;
}
group.requestToPort.set(msg.id as string, port);
group.requestNames.set(msg.id as string, msg.name as string);
group.leaderPort.postMessage({ type: '__to-worker', msg });
return;
} else {
group.followers.delete(port);
portToBudget.delete(port);
unassignedPorts.add(port);
port.postMessage({ type: 'reply', id: msg.id, data: {} });
logState(`Follower left budget "${portBudget}"`);
return;
}
}
// delete-budget: if another group is running this budget, evict it
if (msg.name === 'delete-budget' && msg.args) {
const targetId = (msg.args as Record<string, unknown>).id as string;
if (targetId && budgetGroups.has(targetId)) {
evictGroup(targetId, port);
logState(`Evicted group for deleted budget "${targetId}"`);
}
let hasConnected = false;
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
hasConnected = true;
break;
}
}
if (!hasConnected) {
const tempId = '__deleting-' + Date.now();
electLeader(tempId, port, null, msg);
const newGroup = budgetGroups.get(tempId);
if (newGroup && msg.id) {
newGroup.requestToPort.set(msg.id as string, port);
newGroup.requestNames.set(msg.id as string, msg.name as string);
}
logState(`Tab became leader for budget deletion ("${tempId}")`);
return;
}
}
// Budget-replacing operations
if (
msg.name === 'create-budget' ||
msg.name === 'create-demo-budget' ||
msg.name === 'import-budget' ||
msg.name === 'duplicate-budget' ||
msg.name === 'delete-budget'
) {
if (msg.name === 'create-demo-budget') {
evictGroup('_demo-budget', port);
} else if (
msg.name === 'create-budget' &&
msg.args &&
(msg.args as Record<string, unknown>).testMode
) {
evictGroup('_test-budget', port);
}
if (group && port === group.leaderPort) {
for (const p of group.followers) {
p.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(p);
unassignedPorts.add(p);
}
if (group.followers.size > 0) {
console.log(
`[SharedWorker] Budget-replacing "${msg.name}" — pushed ${group.followers.size} tab(s) off "${portBudget}"`,
);
group.followers.clear();
}
} else {
if (group) {
group.followers.delete(port);
portToBudget.delete(port);
unassignedPorts.add(port);
}
const tempId = '__creating-' + Date.now();
electLeader(tempId, port, null, msg);
const newGroup = budgetGroups.get(tempId);
if (newGroup && msg.id) {
newGroup.requestToPort.set(msg.id as string, port);
newGroup.requestNames.set(msg.id as string, msg.name as string);
}
logState(`Tab became leader for budget creation ("${tempId}")`);
return;
}
}
// ── Default: track and forward to leader ───────────────────
let targetGroup = group;
if (!targetGroup) {
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
targetGroup = g;
break;
}
}
}
if (targetGroup) {
if (msg.id) {
targetGroup.requestToPort.set(msg.id as string, port);
if (msg.name) {
targetGroup.requestNames.set(
msg.id as string,
msg.name as string,
);
}
if (
msg.name === 'load-budget' &&
msg.args &&
(msg.args as Record<string, unknown>).id
) {
targetGroup.requestBudgetIds.set(
msg.id as string,
(msg.args as Record<string, unknown>).id as string,
);
}
}
targetGroup.leaderPort.postMessage({ type: '__to-worker', msg });
}
} catch (error) {
console.error('[SharedWorker] Error in message handler:', error);
}
};
port.start();
}
// ── Public API ────────────────────────────────────────────────────────
function destroy() {
clearInterval(heartbeatId);
}
function getState() {
return {
connectedPorts,
cachedInitMsg,
lastAppInitFailure,
budgetGroups,
portToBudget,
unassignedPorts,
};
}
return { onconnect, destroy, getState };
}

View File

@@ -0,0 +1,11 @@
// SharedWorker entry point for multi-tab, multi-budget support.
//
// All coordinator logic lives in shared-browser-server-core.ts
// This file simply creates a coordinator with console forwarding
// enabled and wires it to the SharedWorkerGlobalScope.
import { createCoordinator } from './shared-browser-server-core';
const coordinator = createCoordinator({ enableConsoleForwarding: true });
(self as unknown as { onconnect: typeof coordinator.onconnect }).onconnect =
coordinator.onconnect;

View File

@@ -230,6 +230,7 @@ const sidebars = {
link: { type: 'doc', id: 'api/index' },
items: [
'api/reference',
'api/cli',
{
type: 'category',
label: 'ActualQL',

View File

@@ -0,0 +1,356 @@
---
title: 'CLI'
---
# CLI Tool
:::danger Experimental — API may change
The CLI is **experimental** and its commands, options, and behavior are **likely to change** in future releases. Use it for scripting and automation with the understanding that updates may require changes to your workflows.
:::
The `@actual-app/cli` package provides a command-line interface for interacting with your Actual Budget data. It connects to your sync server and lets you query and modify budgets, accounts, transactions, categories, payees, rules, schedules, and more — all from the terminal.
:::note
This is different from the [Server CLI](../install/cli-tool.md) (`@actual-app/sync-server`), which is used to host and manage the Actual server itself.
:::
## Installation
Node.js v22 or higher is required.
```bash
npm install --save @actual-app/cli
```
Or install globally:
```bash
npm install --location=global @actual-app/cli
```
## Configuration
The CLI requires a connection to a running Actual sync server. Configuration can be provided via environment variables, CLI flags, or a config file.
### Environment Variables
| Variable | Description |
| ---------------------- | --------------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_PASSWORD` | Server password (one of password or token required) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
### CLI Flags
Global flags override environment variables:
| Flag | Description |
| ------------------------- | ----------------------------------------------- |
| `--server-url <url>` | Server URL |
| `--password <pw>` | Server password |
| `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Local data directory for cached budget data |
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
| `--verbose` | Show informational messages on stderr |
### Config File
The CLI uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for configuration. You can create a config file in any of these formats:
- `.actualrc` (JSON or YAML)
- `.actualrc.json`, `.actualrc.yaml`, `.actualrc.yml`
- `actual.config.json`, `actual.config.yaml`, `actual.config.yml`
- An `"actual"` key in your `package.json`
Example `.actualrc.json`:
```json
{
"serverUrl": "http://localhost:5006",
"password": "your-password",
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
}
```
:::caution Security
Avoid storing plaintext passwords in config files (including the `password` key above). Prefer environment variables such as `ACTUAL_PASSWORD` or `ACTUAL_SESSION_TOKEN`, or use a session token in config instead of a password.
:::
## Usage
```bash
actual <command> <subcommand> [options]
```
## Commands
### Accounts
```bash
# List all accounts
actual accounts list
# Create an account
actual accounts create --name "Checking" [--offbudget] [--balance 50000]
# Update an account
actual accounts update <id> [--name "New Name"] [--offbudget true]
# Close an account (with optional transfer)
actual accounts close <id> [--transfer-account <id>] [--transfer-category <id>]
# Reopen a closed account
actual accounts reopen <id>
# Delete an account
actual accounts delete <id>
# Get account balance
actual accounts balance <id> [--cutoff 2026-01-31]
```
### Budgets
```bash
# List available budgets on the server
actual budgets list
# Download a budget by sync ID
actual budgets download <syncId> [--encryption-password <pw>]
# Sync the current budget
actual budgets sync
# List budget months
actual budgets months
# View a specific month
actual budgets month 2026-03
# Set a budget amount (in integer cents)
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
# Set carryover flag
actual budgets set-carryover --month 2026-03 --category <id> --flag true
# Hold funds for next month
actual budgets hold-next-month --month 2026-03 --amount 10000
# Reset held funds
actual budgets reset-hold --month 2026-03
```
### Categories
```bash
# List all categories
actual categories list
# Create a category
actual categories create --name "Groceries" --group-id <id> [--is-income]
# Update a category
actual categories update <id> [--name "Food"] [--hidden true]
# Delete a category (with optional transfer)
actual categories delete <id> [--transfer-to <id>]
```
### Category Groups
```bash
# List all category groups
actual category-groups list
# Create a category group
actual category-groups create --name "Essentials" [--is-income]
# Update a category group
actual category-groups update <id> [--name "New Name"] [--hidden true]
# Delete a category group (with optional transfer)
actual category-groups delete <id> [--transfer-to <id>]
```
### Transactions
```bash
# List transactions for an account within a date range
actual transactions list --account <id> --start 2026-01-01 --end 2026-03-31
# Add transactions (inline JSON)
actual transactions add --account <id> --data '[{"date":"2026-03-13","amount":-5000,"payee_name":"Store"}]'
# Add transactions (from file)
actual transactions add --account <id> --file transactions.json
# Import transactions with reconciliation (deduplication)
actual transactions import --account <id> --data '[...]' [--dry-run]
# Update a transaction
actual transactions update <id> --data '{"notes":"Updated note"}'
# Delete a transaction
actual transactions delete <id>
```
### Payees
```bash
# List all payees
actual payees list
# List common payees
actual payees common
# Create a payee
actual payees create --name "Grocery Store"
# Update a payee
actual payees update <id> --name "New Name"
# Delete a payee
actual payees delete <id>
# Merge multiple payees into one
actual payees merge --target <id> --ids id1,id2,id3
```
### Tags
```bash
# List all tags
actual tags list
# Create a tag
actual tags create --tag "vacation" [--color "#ff0000"] [--description "Vacation expenses"]
# Update a tag
actual tags update <id> [--tag "trip"] [--color "#00ff00"]
# Delete a tag
actual tags delete <id>
```
### Rules
```bash
# List all rules
actual rules list
# List rules for a specific payee
actual rules payee-rules <payeeId>
# Create a rule (inline JSON)
actual rules create --data '{"stage":"pre","conditionsOp":"and","conditions":[...],"actions":[...]}'
# Create a rule (from file)
actual rules create --file rule.json
# Update a rule
actual rules update --data '{"id":"...","stage":"pre",...}'
# Delete a rule
actual rules delete <id>
```
### Schedules
```bash
# List all schedules
actual schedules list
# Create a schedule
actual schedules create --data '{"name":"Rent","date":"1st","amount":-150000,"amountOp":"is","account":"...","payee":"..."}'
# Update a schedule
actual schedules update <id> --data '{"name":"Updated Rent"}' [--reset-next-date]
# Delete a schedule
actual schedules delete <id>
```
### Query (ActualQL)
Run queries using [ActualQL](./actual-ql/index.md):
```bash
# Run a query (inline)
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
# Run a query (from file)
actual query run --file query.json
```
### Server
```bash
# Get the server version
actual server version
# Look up an entity ID by name
actual server get-id --type accounts --name "Checking"
actual server get-id --type categories --name "Groceries"
# Trigger bank sync
actual server bank-sync [--account <id>]
```
## Amount Convention
All monetary amounts are represented as **integer cents**:
| CLI Value | Dollar Amount |
| --------- | ------------- |
| `5000` | $50.00 |
| `-12350` | -$123.50 |
| `100` | $1.00 |
When providing amounts, always use integer cents. For example, to budget $50, pass `5000`.
## Output Formats
The `--format` flag controls how results are displayed:
- **`json`** (default) — Machine-readable JSON output, ideal for scripting
- **`table`** — Human-readable table format
- **`csv`** — Comma-separated values for spreadsheet import
Use `--verbose` to enable informational messages on stderr for debugging or visibility into what the CLI is doing.
## Common Workflows
**View your budget for the current month:**
```bash
actual budgets month 2026-03 --format table
```
**Check an account balance:**
```bash
# Find the account ID
actual server get-id --type accounts --name "Checking"
# Get the balance
actual accounts balance <id>
```
**Export transactions to CSV:**
```bash
actual transactions list --account <id> --start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
```
**Add a transaction:**
```bash
actual transactions add --account <id> --data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
```
## Error Handling
- Non-zero exit codes indicate an error
- Errors are written as plain text to stderr (e.g., `Error: message`)
- Use `--verbose` to enable informational stderr messages for debugging

View File

@@ -99,6 +99,9 @@ yarn build:desktop
# Build API package
yarn build:api
# Build CLI package
yarn build:cli
# Build sync server
yarn build:server
```
@@ -160,6 +163,9 @@ yarn build:desktop
# API build
yarn build:api
# CLI build
yarn build:cli
# Sync server build
yarn build:server
```

View File

@@ -4,7 +4,7 @@ In the open-source version of Actual, there are 3 NPM packages:
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api): The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with Node.
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web): A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker.
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes a CLI tool, meant to be used with Node.
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes the Server CLI, meant to be used with Node.
All packages and the main Actual release are versioned together. That makes it clear which version of the package should be used with the version of Actual.

View File

@@ -8,7 +8,7 @@ For most cases, we suggest opting for one of the simpler alternatives:
- [Pikapods](/docs/install/pikapods)
- [Desktop Client](/download)
- [CLI tool](/docs/install/cli-tool)
- [Server CLI](/docs/install/cli-tool)
- [Docker](/docs/install/docker)
:::

View File

@@ -1,12 +1,12 @@
---
title: 'CLI tool'
title: 'Server CLI'
---
## Hosting Actual with the CLI tool
## Hosting Actual with the Server CLI
The Actual sync-server is available as an NPM package. The package is designed to make running the sync-server as easy as possible and is published to the official NPM registry under [@@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server).
### Installing the CLI tool
### Installing the Server CLI
Node.js v22 or higher is required for the `@actual-app/sync-server` npm package
@@ -22,7 +22,7 @@ Once installed, you can execute commands directly from your terminal using `actu
> Before running the tool, navigate to the directory that you wish your files to be located.
Run the CLI tool with the following syntax:
Run the Server CLI with the following syntax:
```bash
actual-server [options]
@@ -67,7 +67,7 @@ Reset your password
actual-server --reset-password
```
### Updating the CLI tool
### Updating the Server CLI
The sync server can be updated with a simple command.
@@ -75,7 +75,7 @@ The sync server can be updated with a simple command.
npm update -g @actual-app/sync-server
```
### Uninstalling the CLI tool
### Uninstalling the Server CLI
The sync server can be uninstalled with a simple command.

View File

@@ -47,7 +47,7 @@ While running a server can be a complicated endeavor, we've tried to make it fai
- If you're not comfortable with the command line and are willing to pay a small amount of money to have your version of Actual hosted on the cloud for you, we recommend [PikaPods](pikapods.md).[^2]
- If you're willing to run a few commands in the terminal:
- You can run the server with a simple command using the [CLI tool](cli-tool.md)
- You can run the server with a simple command using the [Server CLI](cli-tool.md)
- [Fly.io](fly.md) also offers cloud hosting for a similar amount of money.
- If you want to use Docker, we have instructions for [using our provided Docker containers](docker.md).
- You could [build Actual from source](build-from-source.md) on macOS, Windows, or Linux if you don't want to use a tool like Docker. (This method is the best option if you want to contribute to Actual's development!)

View File

@@ -25,12 +25,12 @@
"@r74tech/docusaurus-plugin-panzoom": "^2.4.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.2",
"@types/react": "^19.2.5"
"@types/react": "^19.2.14"
},
"browserslist": {
"production": [

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