Compare commits

..

66 Commits

Author SHA1 Message Date
github-actions[bot]
3d47469be3 🔖 (25.11.0) (#6059)
* 🔖 (25.11.0)

* Empty

* Remove used release notes

* Empty

---------

Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-03 18:12:18 -05:00
Matt Fiddaman
cbac6116d4 fix rerender issue with formula card (#6058)
* fix infinite rerender

* note

* remove React import
2025-11-03 01:30:32 +00:00
Michael Clark
e83cfba357 Remove plugin worker temporarily (#6052)
* remove plugin worker temporarily

* releas enotes

* clarifying comment

* remove plugin worker temporarily

* releas enotes

* clarifying comment
2025-11-02 10:18:49 +00:00
Matiss Janis Aboltins
0cac66b203 Remove isGlobal preference functionality (#6049) 2025-11-01 14:18:08 +00:00
Michael Clark
7983ee45e1 :electron: New appx icons (#6043)
* new appx icons

* updates the windows store appx icons to the new style
2025-10-31 21:45:23 +00:00
Michael Clark
844cd3433a :electron: Flathub MetaInfo (#6033)
* updates to flathub metainfo

* update to summary

* release notes

* omit :light

* Update upcoming-release-notes/6033.md

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

* actual budget

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-30 17:15:47 +00:00
dbequeaith
ae6bea2b15 Import qfx safari mobile (#6020)
* Supports selecting qfx files on safari mobile

Fixes #4283

accept explicit MIME types associated with qfx files

* generated release notes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-28 14:20:58 +00:00
Michael Clark
37481535e7 ☁️ Fix server sync file download when server-files are in .config (#6010)
* fix server sync file download when server-files are in .config directory on linux

* extra security

* release notes

* putting it back after testing

* also accounting for directories

* derp
2025-10-27 20:11:40 +00:00
Matiss Janis Aboltins
45a4f0a40d Add sort_by field to custom reports (#6005) 2025-10-27 19:59:53 +00:00
Matt Fiddaman
9a3e33c0d7 fix inconsistent widths of bank sync field mapping selects on mobile (#6007) 2025-10-27 11:56:38 +00:00
Matiss Janis Aboltins
25d072944e Refactor account header to use SpaceBetween component for spacing (#5994) 2025-10-24 20:52:59 +01:00
Joel Jeremy Marquez
cf8a4b6e6a Fix InitialFocus not working on some fields (#5987)
* Fix InitialFocus not working on some fields

* Fix typecheck and lint errors

* Fix lint error

* Add ref type

* Add types

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5987

* Revert vrt

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5987

* Cleanup

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-23 11:34:41 -07:00
David Genord II
55b1ed170b Bump Alpine docker image to 3.22 upgrading from node 18 to 22 (#5989) 2025-10-23 00:42:48 +01:00
Matt Fiddaman
b266ebf1ea 📱 add bank sync settings page to mobile (#5978) 2025-10-22 20:06:44 +01:00
Matiss Janis Aboltins
6826ca0e4b Add virtualizer to mobile transactions (#5921) 2025-10-22 18:07:29 +01:00
Matt Fiddaman
80aee4ee71 ⬆️ bump dependencies before 25.11 (#5983)
* bump dependencies

* note

* recharts differences
2025-10-22 17:28:11 +01:00
dependabot[bot]
ab4aa21343 Bump vite from 7.1.9 to 7.1.11 (#5982)
* Bump vite from 7.1.9 to 7.1.11

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 14:23:36 +01:00
Matt Fiddaman
9dd0284e31 fix report date dropdowns not including current period (#5981) 2025-10-22 13:44:19 +01:00
Matt Fiddaman
dcc879294c fix slow performance in import csv modal (#5980) 2025-10-22 13:43:56 +01:00
lelemm
002f74a8fa Feature: Formula card and Formula for rules (#5939)
* Formula card and Formula rules

* [autofix.ci] apply automated fixes

* File move fix

* [autofix.ci] apply automated fixes

* Update packages/desktop-client/src/components/formula/codeMirror-excelLanguage.tsx

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

* code rabbit wrong commit suggestion fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-21 21:57:56 -03:00
guiza
b6f80c26e6 fix split transaction sort order when duplicating (#5911)
* Fixes #5715

* Release notes

* [autofix.ci] apply automated fixes

* Fix release notes PR number

* Shorten release notes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 01:32:00 +01:00
qunm
ee71130d56 Fixes #4333 Overlapping text on mobile (#5900)
* made Budgeted header to be multiline and increased font minimum font size

* added release note

* Update upcoming-release-notes/5900.md

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

---------

Co-authored-by: Quan Nguyen <quannm@Quans-MacBook-Pro.local>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 01:11:20 +01:00
Andrea Colombo
6ed18d8f8c Update link to documentation in docker-compose.yml (#5873)
* Update link to documentation in docker-compose.yml

* Add release notes
2025-10-21 18:35:55 +01:00
dependabot[bot]
1737674b9e Bump vite from 7.1.9 to 7.1.11 (#5972) 2025-10-21 17:24:56 +01:00
Matt Fiddaman
e4617e8cd4 fix GoCardless institutions with special continuous access EUA behaviour (#5967) 2025-10-21 17:24:05 +01:00
Matiss Janis Aboltins
57d01467ca Refactor test execution to use lage task runner (#5964) 2025-10-21 08:58:26 +02:00
Joel Jeremy Marquez
8019d9f61b Update react compiler to v1 (#5971)
* Update react compiler

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5971

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-20 16:14:30 -07:00
Matiss Janis Aboltins
ddbefc790e Fix date validation bug causing crashes with old dates (#5970) 2025-10-20 22:04:32 +01:00
Matiss Janis Aboltins
7eaf23eb7c Improve e2e test stability (#5966) 2025-10-20 19:21:54 +02:00
Michael Clark
8f284e7b60 :electron: New desktop app icons (#5965)
* add new desktop app icons for better display on linux, and to prepare us for mac liquid glass

* png for windows

* release notes

* put old icons in archived folder
2025-10-20 09:24:59 +01:00
lelemm
be35328e42 🐛 Fix to show the notification for updating service worker (#5963)
* Fix to show the notification for updating service worker

* cleanup

* Add release notes for PR #5963

* trigger pipeline

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-19 22:48:05 -03:00
Michael Clark
23f1b7d3c0 :electron: Change desktop app execution name (#5954)
* change entrypoint name

* release notes
2025-10-19 18:30:09 +01:00
Matiss Janis Aboltins
8b1aa6fb93 Adjust account panel borders and corners (#5958) 2025-10-19 15:51:44 +01:00
Matt Fiddaman
155558ee62 drop support for node 20 (#5937)
* node 24

* node types

* dockerfiles

* readme

* note
2025-10-18 23:58:27 +01:00
Stephen Brown II
94332016e8 Adds balance variable to rule templates (#5925)
* Adds balance variable to rule templates

Enables access to the account balance within rule templates. This allows for more complex rule creation based on the current account balance.

Calculates the account balance up to the transaction being processed, including transactions on the same date with lower sort order.

Handles cases where the balance is undefined gracefully, defaulting to 0 to prevent errors.

## AI disclaimer
This PR contains code that was partially or fully generated by AI and may contain errors. All suggestions for improvement are welcome.

* Add release notes

* indexed sql params not supported

* [autofix.ci] apply automated fixes

* Skip parent transactions of splits

* Uses aql for account balance

Updates transaction rule preparation to use aql instead of  sql calculating it from past transactions.

The balance is defaulted to 0 if no account is set.

Refactor account balance calculation to build a proper query with date and sort_order filters

* Add block scoping to switch cases and ensure correct fallthrough handling in Action type conversions

* Corrects transaction rule sorting order

Reverses the sort order comparison in transaction rules
to ensure correct identification of prior transactions with the same date.

This ensures that the correct balance is used when
calculating balance-based rule conditions.

fixup! Corrects transaction rule sorting order

* Improves transaction rule balance calculation

Uses a more efficient query for calculating the account balance
up to a transaction when applying rules, improving performance.
This change reduces the complexity of the balance calculation.

* Apply coderabbit lessons learned

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-18 08:22:55 -07:00
Matiss Janis Aboltins
0af2c6c2fb Update default dashboard (#5924) 2025-10-18 16:34:41 +02:00
Matiss Janis Aboltins
97482a082d fix: prevent sensitive data leakage in error logs (#5948) 2025-10-18 16:33:27 +02:00
Matiss Janis Aboltins
31a9ba629b Change "/update-vrt" workflow (#5952) 2025-10-18 11:46:52 +01:00
Matiss Janis Aboltins
7c19a6333c docs: add AGENTS.md guide for AI agents (#5942) 2025-10-18 00:29:12 +02:00
Matiss Janis Aboltins
2bce9d707c Improve file decryption modal and button labels (#5943) 2025-10-17 23:47:12 +02:00
Michael Clark
4e42cda29e :electron: Prep for flathub - arm64 support and metainfo (#5947)
* prep for flathub -arm64 build and metainfo

* release note

* spelling mistake

* whitespace removal

* arch build issues

* hmm

* removing arm64 from flatpak - that would get bundled by flathub anyway
2025-10-17 21:08:59 +01:00
Matiss Janis Aboltins
0ce07d7692 Align onboarding form element sizes (#5941) 2025-10-16 22:34:51 +01:00
Matiss Janis Aboltins
0e3415c145 Hide refresh button when not connected to sync server (#5940) 2025-10-16 22:31:53 +01:00
Michael Clark
19675a7de6 ⬆️ Update flatpak sdk and platform (#5935)
* update flatpak sdk and platform

* release notes

* flatpak version config

* going to 24.08 to support as many distros as possible

* remove unneded file
2025-10-16 18:59:19 +01:00
Matt Fiddaman
5a1ceed7d9 ⬆️ bump electron (#5936)
* electron 38.3.0

* electron/notarize 3.1.0

* note
2025-10-16 16:22:48 +01:00
andreparames
86c1c30c97 enhancement: fix Cetelem bank transations (#5914)
* enhancement: fix Cetelem bank transations

The amount coming from GoCardless has the opposite sign of what we expect.

* Cetelem bank: make code more future-proof

Change based on code review suggestion

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

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-16 01:34:27 +01:00
lelemm
96ac1292f9 🐛 Fix Service Worker: New hash every build (#5928)
* New hash every build

* Add release notes for PR #5928

* trigger actions

* Changed from TS to MTS

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-15 13:11:54 -03:00
Matiss Janis Aboltins
328dfae8bf Update DeleteFileModal to disable delete button when server is offline (#5926) 2025-10-14 23:58:12 +02:00
Michael Süssemilch
37247395e2 feat(currency): add currency display to schedules (#5907)
* feat(currency): add currency display to schedules

* refactor: useFormat directly in cell
2025-10-14 20:10:57 +01:00
Daniel Boles
02b0e24d6e budgetfilesSlice: Fix paste-o in fallback sorting by ID (#5918)
* budgetfilesSlice: Fix paste-o in fallback sorting by ID

* Create 5918.md

* 5918.md: Clarify

* 5918.md: Ah, those square brackets don't just indicate a placeholder
2025-10-13 18:57:53 +01:00
Matiss Janis Aboltins
dfaa75f1cf Create e2e tests for mobile payees page (#5905) 2025-10-13 19:42:40 +02:00
Matiss Janis Aboltins
adae3e4352 Virtualize mobile payees list (#5904) 2025-10-13 19:04:32 +02:00
Matiss Janis Aboltins
7a3794295f Enhance mobile rules with undo notifications for save and delete actions (#5906) 2025-10-13 19:02:46 +02:00
Julian Dominguez-Schatz
c1d97fcc75 Allow size-compare action to run immediately after artifact upload (#5912)
* Allow size-compare action to run immediately after artifact upload

* Add release notes
2025-10-13 09:20:18 -07:00
Matiss Janis Aboltins
3715f16888 Add virtualizer to mobile rules list (#5899) 2025-10-11 12:57:39 +02:00
lelemm
edad7ce0e3 New Rule delete transaction action (#4603)
* Delete transaction rule action

* md

* removed debugger

* fix rule execution on csv preview

* Update 4603.md

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-10 21:41:43 -03:00
Matt Fiddaman
737341ffb6 fix hotkey scope warning (#5901) 2025-10-10 19:59:20 +01:00
Matt Fiddaman
6147495003 ⬆️ upgrade recharts to v3 (#5903) 2025-10-10 19:58:59 +01:00
Matiss Janis Aboltins
614bedcfbf Trigger edge docker release on master push (#5897) 2025-10-10 09:37:54 +02:00
Matt Fiddaman
248b1034d7 fix api failing to import (#5896) 2025-10-09 22:55:48 +01:00
Claudio Spizzi
4f88afa266 Fix the Authentik external help URL for OIDC (#5891) 2025-10-08 21:41:16 -03:00
Matt Fiddaman
5c23aad3c2 ⬆️ bump yarn to 4.10.x (#5885)
* bump yarn to 4.10.3

* note

* fix flaky test
2025-10-08 23:21:37 +01:00
Michael Clark
5b9bcc94f6 ⬆️ Upgrade electron-builder (#5857)
* upgrade electron-builder

* no postinstall when yarn immutable

* downgrade to last version without the bug

* fix issue with wrappy

* release notes

* regen yarn lock

* react-sprint

* hmm

* update yarn manually...

* fixing spring?

* lock

* finally

* conflict

* put package back
2025-10-08 22:20:29 +01:00
Matt Fiddaman
87d54251cd ⬆️ bump loot-core dependencies (#5888)
* csv-stringify 6.6.0

* lru-cache 11.2.2

* ua-parser-js 2.0.5

* @reduxjs/toolkit 2.9.0

* jest-diff 30.2.0

* csv-parse 6.1.0

* yargs 18.0.0

* slash 5.1.0

* drop memfs

* peggy 5.0.6

* fast-check 4.3.0

* fake-indexeddb 6.2.2

* note
2025-10-08 21:16:34 +01:00
Matt Fiddaman
2e439aacba ⬆️ bump various dependencies (#5886)
* bump various dependencies

* note

* update playwright images

* globals 16.4.0

* react-router 7.9.4
2025-10-08 21:01:27 +01:00
gdufay
244140314c Enhancements: Use remark plugin in the report's Text widget (#5850)
* Add remark plugin to MarkdownCard

* Generate release note

* [autofix.ci] apply automated fixes

* Avoid duplicate css code

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-08 17:26:31 +01:00
507 changed files with 13643 additions and 87335 deletions

View File

@@ -1,12 +0,0 @@
---
alwaysApply: true
---
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
The following commands can be useful:
- `yarn typecheck` to run typechecker
- `yarn lint` to run the code linter and formatter
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
- `yarn test` to run all the tests

View File

@@ -1,37 +0,0 @@
---
description:
globs: *.ts,*.tsx
alwaysApply: false
---
You are an expert in TypeScript and React.
Code Style and Structure
- Write concise, technical TypeScript code.
- Use functional and declarative programming patterns; avoid classes.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
Naming Conventions
- Favor named exports for components and utilities.
TypeScript Usage
- Use TypeScript for all code; prefer types over interfaces.
- Avoid enums; use objects or maps instead.
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
Syntax and Formatting
- Use the "function" keyword for pure functions.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use declarative JSX, keeping JSX minimal and readable.
Change validation
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct

View File

@@ -1,14 +0,0 @@
---
description:
globs:
alwaysApply: true
---
Vitest test runner is used for unit tests.
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
To run unit tests for a specific package in the monorepo, use the following command:
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.

3
.cursor/worktrees.json Normal file
View File

@@ -0,0 +1,3 @@
{
"setup-worktree": ["yarn"]
}

View File

@@ -17,7 +17,7 @@ runs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Install yarn
run: npm install -g yarn
shell: bash
@@ -32,6 +32,16 @@ runs:
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Ensure Lage cache directory exists
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
shell: bash
- name: Cache Lage
uses: actions/cache@v4
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}
restore-keys: |
lage-${{ runner.os }}-
- name: Install
working-directory: ${{ inputs.working-directory }}
run: yarn --immutable

View File

@@ -24,6 +24,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build API
run: cd packages/api && yarn build
- name: Create package tgz
@@ -40,6 +42,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build CRDT
run: cd packages/crdt && yarn build
- name: Create package tgz
@@ -75,6 +79,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build

View File

@@ -17,6 +17,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Lint
run: yarn lint
typecheck:
@@ -25,6 +27,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Typecheck
run: yarn typecheck
validate-cli:
@@ -33,6 +37,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Web
run: yarn build:server
- name: Check that the built CLI works
@@ -43,6 +49,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Test
run: yarn test
@@ -53,6 +61,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Check migrations
run: node ./.github/actions/check-migrations.js

View File

@@ -1,21 +1,16 @@
name: Build Edge Docker Image
# Edge Docker images are built for every commit, and daily
# Edge Docker images are built for every push to master
on:
push:
branches:
- master
paths:
- 'packages/sync-server/**'
pull_request:
branches:
- master
paths:
- 'packages/sync-server/**'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
concurrency:
group: docker-edge-build
cancel-in-progress: true
permissions:
contents: read
packages: write

View File

@@ -32,11 +32,13 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests on Netlify URL
run: yarn e2e
env:
@@ -53,11 +55,13 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
@@ -74,7 +78,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment

View File

@@ -44,9 +44,9 @@ jobs:
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron for Mac
@@ -57,6 +57,7 @@ jobs:
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron

View File

@@ -39,9 +39,9 @@ jobs:
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron

View File

@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Handle feature requests
run: node .github/actions/handle-feature-requests.js
env:

View File

@@ -73,7 +73,7 @@ jobs:
- name: Setup node and npm registry
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Web

View File

@@ -56,7 +56,7 @@ jobs:
- name: Setup node and npm registry
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Web

View File

@@ -54,6 +54,7 @@ jobs:
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
@@ -62,6 +63,7 @@ jobs:
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: head
allow_forks: true

View File

@@ -1,119 +0,0 @@
name: /update-vrt
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
cancel-in-progress: true
jobs:
update-vrt:
name: Update VRT
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch
run: |
git config --system --add safe.directory "*"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git reset
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git format-patch -1 HEAD --stdout > Update-VRT.patch
- uses: actions/upload-artifact@v4
with:
name: patch
path: Update-VRT.patch
push-patch:
runs-on: ubuntu-latest
needs: update-vrt
permissions:
contents: write
pull-requests: write
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: actions/download-artifact@v4
continue-on-error: true
with:
name: patch
- name: Apply patch and push
env:
BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git apply Update-VRT.patch
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git push origin HEAD:${BRANCH_NAME}
- name: Add finished reaction
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: 'rocket'
add-starting-reaction:
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
permissions:
pull-requests: write
steps:
- name: React to comment
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: '+1'

156
.github/workflows/vrt-update-apply.yml vendored Normal file
View File

@@ -0,0 +1,156 @@
name: VRT Update - Apply
# SECURITY: This workflow runs in trusted base repo context.
# It treats the patch artifact as untrusted data, validates it contains only PNGs,
# and safely applies it to the contributor's fork branch.
on:
workflow_run:
workflows: ['VRT Update - Generate']
types:
- completed
permissions:
contents: write
pull-requests: write
jobs:
apply-vrt-updates:
name: Apply VRT Updates
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: vrt-patch-*
path: /tmp/artifacts
- name: Download metadata artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: vrt-metadata-*
path: /tmp/metadata
- name: Extract metadata
id: metadata
run: |
# Find the metadata directory (will be vrt-metadata-{PR_NUMBER})
METADATA_DIR=$(find /tmp/metadata -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [ -z "$METADATA_DIR" ]; then
echo "No metadata found, skipping..."
exit 0
fi
PR_NUMBER=$(cat "$METADATA_DIR/pr-number.txt")
HEAD_REF=$(cat "$METADATA_DIR/head-ref.txt")
HEAD_REPO=$(cat "$METADATA_DIR/head-repo.txt")
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT"
echo "Found PR #$PR_NUMBER: $HEAD_REPO @ $HEAD_REF"
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@v4
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Validate and apply patch
if: steps.metadata.outputs.pr_number != ''
id: apply
run: |
# Find the patch file
PATCH_DIR=$(find /tmp/artifacts -mindepth 1 -maxdepth 1 -type d | head -n 1)
PATCH_FILE="$PATCH_DIR/vrt-update.patch"
if [ ! -f "$PATCH_FILE" ]; then
echo "No patch file found"
exit 0
fi
echo "Found patch file: $PATCH_FILE"
# Validate patch only contains PNG files
echo "Validating patch contains only PNG files..."
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
exit 1
fi
# Extract file list for verification
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
echo "Patch modifies $FILES_CHANGED PNG file(s)"
# Configure git
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Apply patch
echo "Applying patch..."
if git apply --check "$PATCH_FILE" 2>&1; then
git apply "$PATCH_FILE"
# Stage only PNG files (extra safety)
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes after applying patch"
echo "applied=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Commit
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${{ steps.metadata.outputs.pr_number }}"
echo "applied=true" >> "$GITHUB_OUTPUT"
else
echo "Patch could not be applied cleanly"
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
exit 1
fi
- name: Push changes
if: steps.apply.outputs.applied == 'true'
env:
HEAD_REF: ${{ steps.metadata.outputs.head_ref }}
HEAD_REPO: ${{ steps.metadata.outputs.head_repo }}
run: |
git push origin "HEAD:refs/heads/$HEAD_REF"
echo "Successfully pushed VRT updates to $HEAD_REPO@$HEAD_REF"
- name: Comment on PR - Success
if: steps.apply.outputs.applied == 'true'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ VRT screenshots have been automatically updated.'
});
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@v7
with:
script: |
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.`
});

View File

@@ -0,0 +1,105 @@
name: VRT Update - Generate
# SECURITY: This workflow runs in untrusted fork context with no write permissions.
# It only generates VRT patch artifacts that are later applied by vrt-update-apply.yml
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/**'
- '.github/workflows/vrt-update-generate.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
generate-vrt-updates:
name: Generate VRT Updates
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
continue-on-error: true
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Stage only PNG files
git add "**/*.png"
# Check if there are any changes
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes to commit"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
# Create commit and patch
git commit -m "Update VRT screenshots"
git format-patch -1 HEAD --stdout > vrt-update.patch
# Validate patch only contains PNG files
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files!"
exit 1
fi
echo "Patch created successfully with PNG changes only"
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@v4
with:
name: vrt-patch-${{ github.event.pull_request.number }}
path: vrt-update.patch
retention-days: 5
- name: Save PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
run: |
mkdir -p pr-metadata
echo "${{ github.event.pull_request.number }}" > pr-metadata/pr-number.txt
echo "${{ github.event.pull_request.head.ref }}" > pr-metadata/head-ref.txt
echo "${{ github.event.pull_request.head.repo.full_name }}" > pr-metadata/head-repo.txt
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@v4
with:
name: vrt-metadata-${{ github.event.pull_request.number }}
path: pr-metadata/
retention-days: 5

6
.gitignore vendored
View File

@@ -7,9 +7,6 @@ Actual-*
**/xcuserdata/*
export-2020-01-10.csv
# Secrets
.secret-tokens
# MacOS
.DS_Store
@@ -65,3 +62,6 @@ build/
# .d.ts files aren't type-checked with skipLibCheck set to true
*.d.ts
# Lage cache
.lage/

2
.nvmrc
View File

@@ -1 +1 @@
v20/*
v22/*

View File

@@ -1,2 +0,0 @@
export APPLE_ID=example@email.com
export APPLE_APP_SPECIFIC_PASSWORD=password

942
.yarn/releases/yarn-4.10.3.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
yarnPath: .yarn/releases/yarn-4.10.3.cjs

585
AGENTS.md Normal file
View File

@@ -0,0 +1,585 @@
# AGENTS.md - Guide for AI Agents Working with Actual Budget
This guide provides comprehensive information for AI agents (like Cursor) working with the Actual Budget codebase.
## Project Overview
**Actual Budget** is a local-first personal finance tool written in TypeScript/JavaScript. It's 100% free and open-source with synchronization capabilities across devices.
- **Repository**: https://github.com/actualbudget/actual
- **Community Docs**: https://github.com/actualbudget/docs or https://actualbudget.org/docs
- **License**: MIT
- **Primary Language**: TypeScript (with React)
- **Build System**: Yarn 4 workspaces (monorepo)
## Quick Start Commands
### Essential Commands (Run from Root)
```bash
# Type checking (ALWAYS run before committing)
yarn typecheck
# Linting and formatting (with auto-fix)
yarn lint:fix
# Run all tests
yarn test
# Start development server (browser)
yarn start
# Start with sync server
yarn start:server-dev
# Start desktop app development
yarn start:desktop
```
### Important Rules
- **ALWAYS run yarn commands from the root directory** - never run them in child workspaces
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
- Tests run once and exit by default (using `vitest --run`)
### Task Orchestration with Lage
The project uses **[lage](https://microsoft.github.io/lage/)** (a task runner for JavaScript monorepos) to efficiently run tests and other tasks across multiple workspaces:
- **Parallel execution**: Runs tests in parallel across workspaces for faster feedback
- **Smart caching**: Caches test results to skip unchanged packages (cached in `.lage/` directory)
- **Dependency awareness**: Understands workspace dependencies and execution order
- **Continues on error**: Uses `--continue` flag to run all packages even if one fails
**Lage Commands:**
```bash
# Run all tests across all packages
yarn test # Equivalent to: lage test --continue
# Run tests without cache (for debugging/CI)
yarn test:debug # Equivalent to: lage test --no-cache --continue
```
Configuration is in `lage.config.js` at the project root.
## Architecture & Package Structure
### Core Packages
#### 1. **loot-core** (`packages/loot-core/`)
The core application logic that runs on any platform.
- Business logic, database operations, and calculations
- Platform-agnostic code
- Exports for both browser and node environments
- Test commands:
```bash
# Run all loot-core tests
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
```
#### 2. **desktop-client** (`packages/desktop-client/` - aliased as `@actual-app/web`)
The React-based UI for web and desktop.
- React components using functional programming patterns
- E2E tests using Playwright
- Vite for bundling
- Commands:
```bash
# Development
yarn workspace @actual-app/web start:browser
# Build
yarn workspace @actual-app/web build
# E2E tests
yarn workspace @actual-app/web e2e
# Visual regression tests
yarn workspace @actual-app/web vrt
```
#### 3. **desktop-electron** (`packages/desktop-electron/`)
Electron wrapper for the desktop application.
- Window management and native OS integration
- E2E tests for Electron-specific features
#### 4. **api** (`packages/api/` - aliased as `@actual-app/api`)
Public API for programmatic access to Actual.
- Node.js API
- Designed for integrations and automation
- Commands:
```bash
# Build
yarn workspace @actual-app/api build
# Run tests
yarn workspace @actual-app/api test
# Or use lage to run all tests
yarn test
```
#### 5. **sync-server** (`packages/sync-server/` - aliased as `@actual-app/sync-server`)
Synchronization server for multi-device support.
- Express-based server
- Currently transitioning to TypeScript (mostly JavaScript)
- Commands:
```bash
yarn workspace @actual-app/sync-server start
```
#### 6. **component-library** (`packages/component-library/` - aliased as `@actual-app/components`)
Reusable React UI components.
- Shared components like Button, Input, Menu, etc.
- Theme system and design tokens
- Icons (375+ icons in SVG/TSX format)
#### 7. **crdt** (`packages/crdt/` - aliased as `@actual-app/crdt`)
CRDT (Conflict-free Replicated Data Type) implementation for data synchronization.
- Protocol buffers for serialization
- Core sync logic
#### 8. **plugins-service** (`packages/plugins-service/`)
Service for handling plugins/extensions.
#### 9. **eslint-plugin-actual** (`packages/eslint-plugin-actual/`)
Custom ESLint rules specific to Actual.
- `no-untranslated-strings`: Enforces i18n usage
- `prefer-trans-over-t`: Prefers Trans component over t() function
- `prefer-logger-over-console`: Enforces using logger instead of console
- `typography`: Typography rules
- `prefer-if-statement`: Prefers explicit if statements
## Development Workflow
### 1. Making Changes
When implementing changes:
1. Read relevant files to understand current implementation
2. Make focused, incremental changes
3. Run type checking: `yarn typecheck`
4. Run linting: `yarn lint:fix`
5. Run relevant tests
6. Fix any linter errors that are introduced
### 2. Testing Strategy
**Unit Tests (Vitest)**
The project uses **lage** for running tests across all workspaces efficiently.
```bash
# Run all tests across all packages (using lage)
yarn test
# Run tests without cache (for debugging)
yarn test:debug
# Run tests for a specific package
yarn workspace loot-core run test
# Run a specific test file (watch mode)
yarn workspace loot-core run test path/to/test.test.ts
```
**E2E Tests (Playwright)**
```bash
# Run E2E tests for web
yarn e2e
# Desktop Electron E2E (includes full build)
yarn e2e:desktop
# Visual regression tests
yarn vrt
# Visual regression in Docker (consistent environment)
yarn vrt:docker
# Run E2E tests for a specific package
yarn workspace @actual-app/web e2e
```
**Testing Best Practices:**
- Minimize mocked dependencies - prefer real implementations
- Use descriptive test names
- Vitest globals are available: `describe`, `it`, `expect`, `beforeEach`, etc.
- For sync-server tests, globals are explicitly defined in config
### 3. Type Checking
TypeScript configuration uses:
- Incremental compilation
- Strict type checking with `typescript-strict-plugin`
- Platform-specific exports in `loot-core` (node vs browser)
Always run `yarn typecheck` before committing.
### 4. Internationalization (i18n)
- Use `Trans` component instead of `t()` function when possible
- All user-facing strings must be translated
- Generate i18n files: `yarn generate:i18n`
- Custom ESLint rules enforce translation usage
## Code Style & Conventions
### TypeScript Guidelines
**Type Usage:**
- Use TypeScript for all code
- Prefer `type` over `interface`
- Avoid `enum` - use objects or maps
- Avoid `any` or `unknown` unless absolutely necessary
- Look for existing type definitions in the codebase
- Avoid type assertions (`as`, `!`) - prefer `satisfies`
- Use inline type imports: `import { type MyType } from '...'`
**Naming:**
- Use descriptive variable names with auxiliary verbs (e.g., `isLoaded`, `hasError`)
- Named exports for components and utilities (avoid default exports except in specific cases)
**Code Structure:**
- Functional and declarative programming patterns - avoid classes
- Use the `function` keyword for pure functions
- Prefer iteration and modularization over code duplication
- Structure files: exported component/page, helpers, static content, types
- Create new components in their own files
**React Patterns:**
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
- Use custom hooks from `src/hooks` (not react-router directly):
- `useNavigate()` from `src/hooks` (not react-router)
- `useDispatch()`, `useSelector()`, `useStore()` from `src/redux` (not react-redux)
- Avoid unstable nested components
- Use `satisfies` for type narrowing
**JSX Style:**
- Declarative JSX, minimal and readable
- Avoid unnecessary curly braces in conditionals
- Use concise syntax for simple statements
- Prefer explicit expressions (`condition && <Component />`)
### Import Organization
Imports are automatically organized by ESLint with the following order:
1. React imports (first)
2. Built-in Node.js modules
3. External packages
4. Actual packages (`loot-core`, `@actual-app/components` - legacy pattern `loot-design` may appear in old code)
5. Parent imports
6. Sibling imports
7. Index imports
Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
- Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports
### Restricted Patterns
**Never:**
- Use `console.*` (use logger instead - enforced by ESLint)
- Import from `uuid` without destructuring: use `import { v4 as uuidv4 } from 'uuid'`
- Import colors directly - use theme instead
- Import `@actual-app/web/*` in `loot-core`
**Git Commands:**
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
## File Structure Patterns
### Typical Component File
```typescript
import { type ComponentType } from 'react';
// ... other imports
type MyComponentProps = {
// Props definition
};
export function MyComponent({ prop1, prop2 }: MyComponentProps) {
// Component logic
return (
// JSX
);
}
```
### Test File
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
// ... imports
describe('ComponentName', () => {
it('should behave as expected', () => {
// Test logic
expect(result).toBe(expected);
});
});
```
## Important Directories & Files
### Configuration Files
- `/package.json` - Root workspace configuration, scripts
- `/lage.config.js` - Lage task runner configuration
- `/eslint.config.mjs` - ESLint configuration (flat config format)
- `/tsconfig.json` - Root TypeScript configuration
- `/.cursorignore`, `/.gitignore` - Ignored files
- `/yarn.lock` - Dependency lockfile (Yarn 4)
### Documentation
- `/README.md` - Project overview
- `/CONTRIBUTING.md` - Points to community docs
- `/upcoming-release-notes/` - Release notes for next version
- `/CODEOWNERS` - Code ownership definitions
### Build Artifacts (Don't Edit)
- `packages/*/lib-dist/` - Built output
- `packages/*/dist/` - Built output
- `packages/*/build/` - Built output
- `packages/desktop-client/playwright-report/` - Test reports
- `packages/desktop-client/test-results/` - Test results
- `.lage/` - Lage task runner cache (improves test performance)
### Key Source Directories
- `packages/loot-core/src/client/` - Client-side core logic
- `packages/loot-core/src/server/` - Server-side core logic
- `packages/loot-core/src/shared/` - Shared utilities
- `packages/loot-core/src/types/` - Type definitions
- `packages/desktop-client/src/components/` - React components
- `packages/desktop-client/src/hooks/` - Custom React hooks
- `packages/desktop-client/e2e/` - End-to-end tests
- `packages/component-library/src/` - Reusable components
- `packages/component-library/src/icons/` - Icon components (auto-generated, don't edit)
## Common Development Tasks
### Running Specific Tests
```bash
# Run all tests across all packages (recommended)
yarn test
# Unit test for a specific file in loot-core (watch mode)
yarn workspace loot-core run test src/path/to/file.test.ts
# E2E test for a specific file
yarn workspace @actual-app/web run playwright test accounts.test.ts --browser=chromium
```
### Building for Production
```bash
# Browser build
yarn build:browser
# Desktop build
yarn build:desktop
# API build
yarn build:api
# Sync server build
yarn build:server
```
### Type Checking Specific Packages
TypeScript uses project references. Run `yarn typecheck` from root to check all packages.
### Debugging Tests
```bash
# Run tests in debug mode (without parallelization)
yarn test:debug
# Run specific E2E test with headed browser
yarn workspace @actual-app/web run playwright test --headed --debug accounts.test.ts
```
### Working with Icons
Icons in `packages/component-library/src/icons/` are auto-generated. Don't manually edit them.
## Troubleshooting
### Type Errors
1. Run `yarn typecheck` to see all type errors
2. Check if types are imported correctly
3. Look for existing type definitions in `packages/loot-core/src/types/`
4. Use `satisfies` instead of `as` for type narrowing
### Linter Errors
1. Run `yarn lint:fix` to auto-fix many issues
2. Check ESLint output for specific rule violations
3. Custom rules:
- `actual/no-untranslated-strings` - Add i18n
- `actual/prefer-trans-over-t` - Use Trans component
- `actual/prefer-logger-over-console` - Use logger
- Check `eslint.config.mjs` for complete rules
### Test Failures
1. Check if test is running in correct environment (node vs web)
2. For Vitest: check `vitest.config.ts` or `vitest.web.config.ts`
3. For Playwright: check `playwright.config.ts`
4. Ensure mock minimization - prefer real implementations
5. **Lage cache issues**: Clear cache with `rm -rf .lage` if tests behave unexpectedly
6. **Tests continue on error**: With `--continue` flag, all packages run even if one fails
### Import Resolution Issues
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
2. Reinstall dependencies: `yarn install`
3. Check Node.js version (requires >=20)
4. Check Yarn version (requires ^4.9.1)
## Testing Patterns
### Unit Tests
- Located alongside source files or in `__tests__` directories
- Use `.test.ts`, `.test.tsx`, `.spec.js` extensions
- Vitest is the test runner
- Minimize mocking - prefer real implementations
### E2E Tests
- Located in `packages/desktop-client/e2e/`
- Use Playwright test runner
- Visual regression snapshots in `*-snapshots/` directories
- Page models in `e2e/page-models/` for reusable page interactions
- Mobile tests have `.mobile.test.ts` suffix
### Visual Regression Tests (VRT)
- Snapshots stored per test file in `*-snapshots/` directories
- Use Docker for consistent environment: `yarn vrt:docker`
## Additional Resources
- **Community Documentation**: https://actualbudget.org/docs/contributing/
- **Discord Community**: https://discord.gg/pRYNYr4W5A
- **GitHub Issues**: https://github.com/actualbudget/actual/issues
- **Feature Requests**: Label "needs votes" sorted by reactions
## Code Quality Checklist
Before committing changes, ensure:
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] No new console.\* usage (use logger)
- [ ] User-facing strings are translated
- [ ] Prefer `type` over `interface`
- [ ] Named exports used (not default exports)
- [ ] Imports are properly ordered
- [ ] Platform-specific code uses proper exports
- [ ] No unnecessary type assertions
## Pull Request Guidelines
When creating pull requests:
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
## Performance Considerations
- **Bundle Size**: Check with rollup-plugin-visualizer
- **Type Checking**: Uses incremental compilation
- **Testing**: Tests run in parallel by default
- **Linting**: ESLint caches results for faster subsequent runs
## Workspace Commands Reference
```bash
# List all workspaces
yarn workspaces list
# Run command in specific workspace
yarn workspace <workspace-name> run <command>
# Run command in all workspaces
yarn workspaces foreach --all run <command>
# Install production dependencies only (for server deployment)
yarn install:server
```
## Environment Requirements
- **Node.js**: >=20
- **Yarn**: ^4.9.1 (managed by packageManager field)
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
## Migration Notes
The codebase is actively being migrated:
- **JavaScript → TypeScript**: sync-server is in progress
- **Classes → Functions**: Prefer functional patterns
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
When working with older code, follow the newer patterns described in this guide.

View File

@@ -5,7 +5,7 @@
# you are doing.
###################################################
FROM node:20-bullseye as dev
FROM node:22-bookworm as dev
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
WORKDIR /app
CMD ["sh", "./bin/docker-start"]

View File

@@ -1,605 +0,0 @@
# Actual Budget Plugin Architecture
## Overview
Actual Budget's plugin system enables extending the sync-server with custom functionality through isolated, sandboxed processes. Plugins run as separate Node.js child processes that communicate with the sync-server via Inter-Process Communication (IPC).
## Key Concepts
### Plugin Structure
A plugin is a standalone Node.js application that:
- **Runs as a child process** forked from the sync-server
- **Uses Express.js** to define HTTP-like routes
- **Communicates via IPC** instead of network sockets
- **Has isolated dependencies** and runtime environment
### Core Components
1. **Plugin Manager** (`sync-server`) - Discovers, loads, and manages plugin lifecycle
2. **Plugin Middleware** (`sync-server`) - Routes HTTP requests to appropriate plugins via IPC
3. **Plugin Core Library** (`@actual-app/plugins-core-sync-server`) - Utilities for plugin authors
4. **Plugin Process** - Your custom plugin code running as a child process
---
## Plugin Development
### 1. Project Setup
```bash
# Create plugin directory
mkdir my-plugin
cd my-plugin
# Initialize npm project
npm init -y
# Install dependencies
npm install express @actual-app/plugins-core-sync-server
npm install -D typescript @types/express @types/node
```
### 2. Create Manifest
Every plugin needs a `manifest.ts` file that describes the plugin:
```typescript
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
export const manifest: PluginManifest = {
name: 'my-plugin',
version: '1.0.0',
description: 'My awesome plugin',
entry: 'dist/index.js',
author: 'Your Name',
license: 'MIT',
routes: [
{
path: '/hello',
methods: ['GET', 'POST'],
auth: 'authenticated', // or 'anonymous'
description: 'Hello endpoint',
},
],
bankSync: {
// Optional: for bank sync plugins
enabled: true,
displayName: 'My Bank Provider',
description: 'Connect accounts via my provider',
requiresAuth: true,
endpoints: {
status: '/status',
accounts: '/accounts',
transactions: '/transactions',
},
},
};
export default manifest;
```
### 3. Create Plugin Code
```typescript
import express from 'express';
import {
attachPluginMiddleware,
saveSecret,
getSecret,
} from '@actual-app/plugins-core-sync-server';
const app = express();
// Essential: Parse JSON request bodies
app.use(express.json());
// Essential: Enable IPC communication with sync-server
attachPluginMiddleware(app);
// Define your routes
app.get('/hello', (req, res) => {
res.json({ message: 'Hello from plugin!' });
});
app.post('/save-config', async (req, res) => {
const { apiKey } = req.body;
// Save secrets (encrypted & user-scoped)
await saveSecret(req, 'apiKey', apiKey);
res.json({ success: true });
});
app.get('/config', async (req, res) => {
// Retrieve secrets
const result = await getSecret(req, 'apiKey');
res.json({ configured: !!result.value });
});
// No need to call app.listen() - IPC handles communication
console.log('My plugin loaded successfully');
```
### 4. Build Configuration
```json
{
"scripts": {
"build": "tsc && node build-manifest.js",
"dev": "tsc --watch"
}
}
```
The build process should:
1. Compile TypeScript to JavaScript
2. Convert `manifest.ts` to `manifest.json`
---
## Plugin Loading Process
```mermaid
flowchart TD
A[Sync-Server Starts] --> B[Initialize PluginManager]
B --> C[Scan plugins-api Directory]
C --> D{Find Plugins}
D -->|For each plugin| E[Read manifest.json]
E --> F{Valid Manifest?}
F -->|No| G[Skip Plugin]
F -->|Yes| H[Fork Child Process]
H --> I[Pass Environment Variables]
I --> J[Plugin Process Starts]
J --> K[attachPluginMiddleware Called]
K --> L[Plugin Sends 'ready' Message]
L --> M{Ready within timeout?}
M -->|No| N[Reject Plugin]
M -->|Yes| O[Mark Plugin as Online]
O --> P[Register Routes]
P --> Q[Plugin Available]
style A fill:#e1f5ff
style Q fill:#d4edda
style G fill:#f8d7da
style N fill:#f8d7da
```
### Loading Sequence Diagram
```mermaid
sequenceDiagram
participant SS as Sync-Server
participant PM as PluginManager
participant FS as File System
participant PP as Plugin Process
SS->>PM: Initialize(pluginsDir)
SS->>PM: loadPlugins()
PM->>FS: Read plugins-api directory
FS-->>PM: List of plugin folders
loop For each plugin
PM->>FS: Read manifest.json
FS-->>PM: Manifest data
PM->>PM: Validate manifest
PM->>PP: fork(entryPoint)
Note over PP: Plugin process starts
PP->>PP: Create Express app
PP->>PP: Define routes
PP->>PP: attachPluginMiddleware()
PP-->>PM: IPC: {type: 'ready'}
PM->>PM: Mark plugin as online
PM->>PM: Register routes
end
PM-->>SS: All plugins loaded
```
---
## Communication Architecture
### HTTP Request Flow
When a client makes a request to a plugin endpoint:
```mermaid
sequenceDiagram
participant C as Client
participant SS as Sync-Server
participant PM as PluginMiddleware
participant MGR as PluginManager
participant PP as Plugin Process
C->>SS: POST /plugins-api/my-plugin/hello
SS->>PM: Route to plugin middleware
PM->>PM: Extract plugin slug & route
PM->>PM: Check authentication
PM->>PM: Verify route permissions
PM->>MGR: sendRequest(pluginSlug, requestData)
MGR->>PP: IPC: {type: 'request', method, path, body}
Note over PP: Plugin receives IPC message
PP->>PP: Simulate HTTP request
PP->>PP: Route to Express handler
PP->>PP: Execute business logic
PP-->>MGR: IPC: {type: 'response', status, body}
MGR-->>PM: Response data
PM-->>SS: Forward response
SS-->>C: HTTP Response
```
### IPC Message Types
```mermaid
flowchart LR
subgraph "Sync-Server → Plugin"
A[request<br/>HTTP request data]
B[secret-response<br/>Secret value response]
end
subgraph "Plugin → Sync-Server"
C[ready<br/>Plugin initialized]
D[response<br/>HTTP response data]
E[secret-get<br/>Request secret]
F[secret-set<br/>Save secret]
G[error<br/>Error occurred]
end
style A fill:#fff3cd
style B fill:#fff3cd
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d1ecf1
style F fill:#d1ecf1
style G fill:#f8d7da
```
---
## Secrets Management
Plugins can store encrypted, user-scoped secrets (API keys, tokens, etc.):
```mermaid
sequenceDiagram
participant PH as Plugin Handler
participant PC as Plugin Core
participant PP as Plugin Process (IPC)
participant PM as PluginManager
participant SS as Secrets Store
Note over PH: User saves API key
PH->>PC: saveSecret(req, 'apiKey', 'abc123')
PC->>PC: Namespace: 'my-plugin_apiKey'
PC->>PP: process.send({type: 'secret-set'})
PP-->>PM: IPC: secret-set message
PM->>SS: Store secret (encrypted)
SS-->>PM: Success
PM-->>PP: IPC: secret-response
PP-->>PC: Promise resolves
PC-->>PH: {success: true}
Note over PH: Later: retrieve secret
PH->>PC: getSecret(req, 'apiKey')
PC->>PP: process.send({type: 'secret-get'})
PP-->>PM: IPC: secret-get message
PM->>SS: Retrieve secret
SS-->>PM: Decrypted value
PM-->>PP: IPC: secret-response
PP-->>PC: Promise resolves
PC-->>PH: {value: 'abc123'}
```
**Key Features:**
- **User-scoped**: Each user has their own secrets
- **Encrypted**: Stored securely in the database
- **Namespaced**: Automatically prefixed with plugin slug
- **Async**: Uses IPC promises for retrieval
---
## Plugin Architecture Diagram
```mermaid
flowchart TB
subgraph Client["Client (Browser/App)"]
UI[User Interface]
end
subgraph SyncServer["Sync-Server Process"]
HTTP[HTTP Server]
AUTH[Authentication]
API[API Routes]
PMW[Plugin Middleware]
MGR[Plugin Manager]
SEC[Secrets Store]
end
subgraph Plugin1["Plugin Process 1"]
P1APP[Express App]
P1MW[Plugin Middleware]
P1ROUTES[Route Handlers]
P1LOGIC[Business Logic]
end
subgraph Plugin2["Plugin Process 2"]
P2APP[Express App]
P2MW[Plugin Middleware]
P2ROUTES[Route Handlers]
P2LOGIC[Business Logic]
end
UI -->|HTTP Request| HTTP
HTTP --> AUTH
AUTH --> API
API --> PMW
PMW -->|Route| MGR
MGR <-->|IPC<br/>Messages| P1MW
MGR <-->|IPC<br/>Messages| P2MW
P1MW --> P1APP
P1APP --> P1ROUTES
P1ROUTES --> P1LOGIC
P2MW --> P2APP
P2APP --> P2ROUTES
P2ROUTES --> P2LOGIC
P1LOGIC <-.->|Secret<br/>Requests| MGR
P2LOGIC <-.->|Secret<br/>Requests| MGR
MGR <-.-> SEC
style Client fill:#e1f5ff
style SyncServer fill:#fff3cd
style Plugin1 fill:#d4edda
style Plugin2 fill:#d4edda
```
---
## Bank Sync Plugins
Bank sync plugins follow a specific contract to integrate with Actual's account linking:
### Required Endpoints
1. **`/status`** - Check if plugin is configured
```json
Response: {
"status": "ok",
"data": { "configured": true }
}
```
2. **`/accounts`** - Fetch available accounts
```json
Response: {
"status": "ok",
"data": {
"accounts": [
{
"account_id": "ext-123",
"name": "Checking",
"institution": "My Bank",
"balance": 1000,
"mask": "1234",
"official_name": "Primary Checking",
"orgDomain": "mybank.com",
"orgId": "bank-001"
}
]
}
}
```
3. **`/transactions`** - Fetch transactions
```json
Request: {
"accountId": "ext-123",
"startDate": "2024-01-01"
}
Response: {
"status": "ok",
"data": {
"transactions": {
"booked": [...],
"pending": [...]
}
}
}
```
---
## Best Practices
### 1. Error Handling
```typescript
app.post('/endpoint', async (req, res) => {
try {
const result = await doSomething();
res.json({ status: 'ok', data: result });
} catch (error) {
res.json({
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
```
### 2. Input Validation
```typescript
app.post('/config', async (req, res) => {
const { apiKey } = req.body;
if (!apiKey || typeof apiKey !== 'string') {
return res.json({
status: 'error',
error: 'apiKey is required',
});
}
// Process...
});
```
### 3. Logging
```typescript
// Plugin stdout/stderr is visible in sync-server logs
console.log('[MY-PLUGIN] Processing request...');
console.error('[MY-PLUGIN] Error occurred:', error);
```
### 4. Graceful Shutdown
```typescript
process.on('SIGTERM', () => {
console.log('[MY-PLUGIN] Shutting down...');
// Cleanup resources
process.exit(0);
});
```
---
## Deployment
### File Structure
```
sync-server/
└── user-files/
└── plugins-api/
└── my-plugin/
├── manifest.json
├── package.json
├── node_modules/
└── dist/
└── index.js
```
### Installation Steps
1. **Build the plugin** (as ZIP or folder)
2. **Place in plugins-api directory**
3. **Restart sync-server** (auto-loads on startup)
### ZIP Format (Recommended)
```
my-plugin.zip
├── manifest.json
├── package.json
├── node_modules/
└── dist/
└── index.js
```
The plugin manager automatically extracts ZIPs to a temporary directory.
---
## Troubleshooting
### Plugin Not Loading
- Check `manifest.json` exists and is valid JSON
- Verify `entry` field points to correct file
- Check sync-server logs for error messages
### IPC Communication Failures
- Ensure `attachPluginMiddleware(app)` is called
- Verify plugin sends `ready` message within 10s timeout
- Check that `process.send` is available (forked process)
### Route Not Found
- Verify route is defined in `manifest.json`
- Check authentication requirements match
- Ensure route path matches exactly (case-sensitive)
### Secrets Not Persisting
- Confirm user is authenticated
- Check `pluginSlug` is passed in request context
- Verify secrets store is properly initialized
---
## Example: Complete Bank Sync Plugin
See the [Pluggy.ai plugin](packages/bank-sync-plugin-pluggy.ai/) for a full working example that demonstrates:
- Authentication and configuration
- Account fetching with proper typing
- Transaction synchronization
- Secret management
- Error handling
- TypeScript usage
---
## API Reference
### `attachPluginMiddleware(app: Express)`
Enables IPC communication for the plugin. Must be called before defining routes.
### `saveSecret(req: Request, key: string, value: string)`
Saves an encrypted, user-scoped secret.
### `getSecret(req: Request, key: string)`
Retrieves a secret by key.
### `saveSecrets(req: Request, secrets: Record<string, string>)`
Saves multiple secrets at once.
### `getSecrets(req: Request, keys: string[])`
Retrieves multiple secrets at once.
---
## Security Considerations
1. **Process Isolation** - Each plugin runs in its own process
2. **Route Authentication** - Manifest declares auth requirements
3. **Secret Encryption** - All secrets encrypted at rest
4. **User Scoping** - Secrets isolated per user
5. **Namespace Isolation** - Secrets auto-prefixed with plugin slug
6. **No Direct DB Access** - Plugins can't access database directly
7. **Controlled IPC** - Only specific message types allowed

View File

@@ -62,14 +62,11 @@ yarn workspace desktop-electron update-client
echo "Skipping exe build"
else
if [ "$RELEASE" == "production" ]; then
if [ -f ../../.secret-tokens ]; then
source ../../.secret-tokens
fi
yarn build
echo "Created release"
else
SKIP_NOTARIZATION=true yarn build
yarn build
fi
fi
)

View File

@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
echo "E2E_START_URL: $E2E_START_URL"
echo "VRT_ARGS: $VRT_ARGS"
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -1,5 +1,6 @@
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginReact from 'eslint-plugin-react';
@@ -71,18 +72,15 @@ const confusingBrowserGlobals = [
'top',
];
export default pluginTypescript.config(
export default defineConfig(
{
ignores: [
// Global ignore patterns
'**/node_modules/**',
'**/dist/**',
'**/*.zip',
// Specific ignore patterns
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/dist',
'packages/api/@types',
'packages/api/migrations',
'packages/crdt/dist',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/build/',
@@ -91,13 +89,18 @@ export default pluginTypescript.config(
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
'packages/desktop-client/public/data/',
'packages/desktop-client/**/node_modules/*',
'packages/desktop-client/node_modules/',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
'packages/desktop-electron/build/',
'packages/desktop-electron/dist/',
'packages/loot-core/**/node_modules/*',
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/build/',
'packages/plugins-service/dist/',
'.yarn/*',
'.github/*',
],
@@ -763,18 +766,6 @@ export default pluginTypescript.config(
'react-hooks/exhaustive-deps': 'off',
},
},
{
files: ['**/*.cjs'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
files: ['**/manifest.ts'],
rules: {
'import/no-default-export': 'off',
},
},
{
files: [
'eslint.config.mjs',

30
lage.config.js Normal file
View File

@@ -0,0 +1,30 @@
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
test: {
type: 'npmScript',
options: {
outputGlob: [
'coverage/**',
'**/test-results/**',
'**/playwright-report/**',
],
},
},
build: {
type: 'npmScript',
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
},
cacheOptions: {
cacheStorageConfig: {
provider: 'local',
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
npmClient: 'yarn',
concurrency: 2,
};

View File

@@ -40,12 +40,12 @@
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "yarn workspaces foreach --all --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
"test": "lage test --continue",
"test:debug": "lage test --no-cache --continue",
"e2e": "yarn workspace @actual-app/web run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
@@ -58,9 +58,9 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.18.8",
"@types/node": "^22.18.11",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.45.0",
"@typescript-eslint/parser": "^8.46.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
@@ -68,22 +68,23 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"globals": "^16.4.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.14",
"lint-staged": "^16.2.3",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"p-limit": "^7.1.1",
"prettier": "^3.6.2",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"typescript-eslint": "^8.46.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
@@ -91,7 +92,7 @@
"socks": ">=2.8.3"
},
"engines": {
"node": ">=20",
"node": ">=22",
"yarn": "^4.9.1"
},
"lint-staged": {
@@ -100,7 +101,7 @@
"prettier --write"
]
},
"packageManager": "yarn@4.9.1",
"packageManager": "yarn@4.10.3",
"browserslist": [
"electron >= 35.0",
"defaults"

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "25.10.0",
"version": "25.11.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -19,7 +19,7 @@
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run build:app && yarn run build:crdt && vitest",
"test": "yarn run build:app && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types"
},
"dependencies": {

View File

@@ -1 +1,6 @@
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';
// @ts-ignore: bundle not available until we build it
// eslint-disable-next-line import/extensions
import * as bundle from './app/bundle.api.js';
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;

View File

@@ -5,5 +5,11 @@ export default {
// print only console.error
return type === 'stderr';
},
poolOptions: {
threads: {
maxThreads: 2,
minThreads: 1,
},
},
},
};

View File

@@ -1,11 +0,0 @@
node_modules/
dist/
*.log
.DS_Store
.env
.env.local
# Generated build artifacts
manifest.json
*.zip

File diff suppressed because one or more lines are too long

View File

@@ -1,459 +0,0 @@
import { attachPluginMiddleware, saveSecret, getSecret, BankSyncErrorCode, } from '@actual-app/plugins-core-sync-server';
import express from 'express';
import { PluggyClient } from 'pluggy-sdk';
// Import manifest (used during build)
import './manifest';
// Create Express app
const app = express();
// Use JSON middleware for parsing request bodies
app.use(express.json());
// Attach the plugin middleware to enable IPC communication with sync-server
attachPluginMiddleware(app);
// Pluggy client singleton
let pluggyClient = null;
async function getPluggyClient(req) {
// Try to get credentials from secrets first
const clientIdResult = await getSecret(req, 'clientId');
const clientSecretResult = await getSecret(req, 'clientSecret');
const clientId = clientIdResult.value || req.body.clientId;
const clientSecret = clientSecretResult.value || req.body.clientSecret;
if (!clientId || !clientSecret) {
throw new Error('Pluggy.ai credentials not configured');
}
if (!pluggyClient) {
pluggyClient = new PluggyClient({
clientId,
clientSecret,
});
}
return pluggyClient;
}
/**
* GET /status
* Check if Pluggy.ai is configured
*/
app.get('/status', async (req, res) => {
try {
const clientIdResult = await getSecret(req, 'clientId');
const configured = clientIdResult.value != null;
res.json({
status: 'ok',
data: {
configured,
},
});
}
catch (error) {
res.json({
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /accounts
* Fetch accounts from Pluggy.ai
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
*
* If clientId and clientSecret are provided, they will be saved as secrets
*/
app.post('/accounts', async (req, res) => {
try {
const { itemIds, clientId, clientSecret } = req.body;
// If credentials are provided in request, save them
if (clientId && clientSecret) {
await saveSecret(req, 'clientId', clientId);
await saveSecret(req, 'clientSecret', clientSecret);
}
// Get itemIds from request or from stored secrets
let itemIdsArray;
if (itemIds) {
// Parse itemIds from request (can be comma-separated string or array)
if (typeof itemIds === 'string') {
itemIdsArray = itemIds.split(',').map((id) => id.trim());
}
else if (Array.isArray(itemIds)) {
itemIdsArray = itemIds;
}
else {
res.json({
status: 'error',
error: 'itemIds must be a string or array',
});
return;
}
// Save itemIds for future use
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
}
else {
// Try to get itemIds from secrets
const storedItemIds = await getSecret(req, 'itemIds');
if (!storedItemIds.value) {
res.json({
status: 'error',
error: 'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
});
return;
}
itemIdsArray = storedItemIds.value
.split(',')
.map((id) => id.trim());
}
if (!itemIdsArray.length) {
res.json({
status: 'error',
error: 'At least one item ID is required',
});
return;
}
const client = await getPluggyClient(req);
let accounts = [];
// Fetch all accounts and their items with connector info
for (const itemId of itemIdsArray) {
const partial = await client.fetchAccounts(itemId);
// For each account, also fetch the item to get connector details
for (const account of partial.results) {
try {
const item = await client.fetchItem(itemId);
// Attach item info to account for transformation
account.itemData = item;
}
catch (error) {
console.error(`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`, error);
}
}
accounts = accounts.concat(partial.results);
}
// Transform Pluggy accounts to GenericBankSyncAccount format
const transformedAccounts = accounts.map((account) => {
const institution = account.itemData?.connector?.name ||
account.item?.connector?.name ||
'Unknown Institution';
const connectorId = account.itemData?.connector?.id ||
account.item?.connector?.id ||
account.itemId;
return {
account_id: account.id,
name: account.name,
institution,
balance: account.balance || 0,
mask: account.number?.substring(account.number.length - 4),
official_name: account.name,
orgDomain: account.itemData?.connector?.institutionUrl ||
account.item?.connector?.institutionUrl ||
null,
orgId: connectorId?.toString() || null,
};
});
res.json({
status: 'ok',
data: {
accounts: transformedAccounts,
},
});
}
catch (error) {
console.error('[PLUGGY ACCOUNTS] Error:', error);
// Extract Pluggy error message and code if available
let pluggyMessage = 'Unknown error';
let pluggyCode;
if (error instanceof Error) {
pluggyMessage = error.message;
// Try to parse Pluggy SDK error format from error message
// Pluggy errors often include the error details in the message
try {
// Check if error has a structured format
const errorAny = error;
if (errorAny.message && typeof errorAny.message === 'string') {
pluggyMessage = errorAny.message;
}
if (errorAny.code !== undefined) {
pluggyCode = errorAny.code;
}
}
catch (e) {
// Ignore parse errors
}
}
const errorResponse = {
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
status: 'error',
reason: pluggyMessage, // Use the Pluggy error message directly
};
// Map HTTP status codes to error types
const errorMessageLower = pluggyMessage.toLowerCase();
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
}
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
}
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
}
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
}
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
}
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
}
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
}
errorResponse.details = {
originalError: pluggyMessage,
pluggyCode: pluggyCode,
};
res.json({
status: 'ok',
data: errorResponse,
});
}
});
/**
* POST /transactions
* Fetch transactions from Pluggy.ai
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
*/
app.post('/transactions', async (req, res) => {
try {
const { accountId, startDate } = req.body;
if (!accountId) {
res.json({
status: 'error',
error: 'accountId is required',
});
return;
}
const client = await getPluggyClient(req);
const transactions = await getTransactions(client, accountId, startDate);
const account = (await client.fetchAccount(accountId));
let startingBalance = parseInt(Math.round(account.balance * 100).toString());
if (account.type === 'CREDIT') {
startingBalance = -startingBalance;
}
const date = getDate(new Date(account.updatedAt));
const balances = [
{
balanceAmount: {
amount: startingBalance,
currency: account.currencyCode,
},
balanceType: 'expected',
referenceDate: date,
},
];
const all = [];
const booked = [];
const pending = [];
for (const trans of transactions) {
const transRecord = trans;
const newTrans = {};
newTrans.booked = !(transRecord.status === 'PENDING');
const transactionDate = new Date(transRecord.date);
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
continue;
}
newTrans.date = getDate(transactionDate);
newTrans.payeeName = getPayeeName(transRecord);
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
if (account.type === 'CREDIT') {
if (transRecord.amountInAccountCurrency) {
transRecord.amountInAccountCurrency =
transRecord.amountInAccountCurrency * -1;
}
transRecord.amount = transRecord.amount * -1;
}
let amountInCurrency = transRecord.amountInAccountCurrency ??
transRecord.amount;
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
newTrans.transactionAmount = {
amount: amountInCurrency,
currency: transRecord.currencyCode,
};
newTrans.transactionId = transRecord.id;
newTrans.sortOrder = transactionDate.getTime();
delete transRecord.amount;
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
if (newTrans.booked) {
booked.push(finalTrans);
}
else {
pending.push(finalTrans);
}
all.push(finalTrans);
}
const sortFunction = (a, b) => {
const aRec = a;
const bRec = b;
return bRec.sortOrder - aRec.sortOrder;
};
const bookedSorted = booked.sort(sortFunction);
const pendingSorted = pending.sort(sortFunction);
const allSorted = all.sort(sortFunction);
res.json({
status: 'ok',
data: {
balances,
startingBalance,
transactions: {
all: allSorted,
booked: bookedSorted,
pending: pendingSorted,
},
},
});
}
catch (error) {
console.error('[PLUGGY TRANSACTIONS] Error:', error);
// Extract Pluggy error message and code if available
let pluggyMessage = 'Unknown error';
let pluggyCode;
if (error instanceof Error) {
pluggyMessage = error.message;
// Try to parse Pluggy SDK error format from error message
try {
const errorAny = error;
if (errorAny.message && typeof errorAny.message === 'string') {
pluggyMessage = errorAny.message;
}
if (errorAny.code !== undefined) {
pluggyCode = errorAny.code;
}
}
catch (e) {
// Ignore parse errors
}
}
const errorResponse = {
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
status: 'error',
reason: pluggyMessage, // Use the Pluggy error message directly
};
// Map HTTP status codes to error types
const errorMessageLower = pluggyMessage.toLowerCase();
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
}
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
}
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
}
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
}
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
}
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
}
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
}
errorResponse.details = {
originalError: pluggyMessage,
pluggyCode: pluggyCode,
};
res.json({
status: 'ok',
data: errorResponse,
});
}
});
// Helper functions
async function getTransactions(client, accountId, startDate) {
let transactions = [];
let result = await getTransactionsByAccountId(client, accountId, startDate, 500, 1);
transactions = transactions.concat(result.results);
const totalPages = result.totalPages;
let currentPage = result.page;
while (currentPage !== totalPages) {
result = await getTransactionsByAccountId(client, accountId, startDate, 500, currentPage + 1);
transactions = transactions.concat(result.results);
currentPage = result.page;
}
return transactions;
}
async function getTransactionsByAccountId(client, accountId, startDate, pageSize, page) {
const account = (await client.fetchAccount(accountId));
// Sandbox account handling
const sandboxAccount = account.owner === 'John Doe';
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
const transactions = await client.fetchTransactions(accountId, {
from: fromDate,
pageSize,
page,
});
if (sandboxAccount) {
const mappedResults = transactions.results.map((t) => ({
...t,
sandbox: true,
}));
transactions.results =
mappedResults;
}
return transactions;
}
function getDate(date) {
return date.toISOString().split('T')[0];
}
function flattenObject(obj, prefix = '') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (value === null) {
continue;
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenObject(value, newKey));
}
else {
result[newKey] = value;
}
}
return result;
}
function getPayeeName(trans) {
const merchant = trans.merchant;
if (merchant && (merchant.name || merchant.businessName)) {
return merchant.name || merchant.businessName || '';
}
const paymentData = trans.paymentData;
if (paymentData) {
const { receiver, payer } = paymentData;
if (trans.type === 'DEBIT' && receiver) {
const receiverData = receiver;
const docNum = receiverData.documentNumber;
return receiverData.name || docNum?.value || '';
}
if (trans.type === 'CREDIT' && payer) {
const payerData = payer;
const docNum = payerData.documentNumber;
return payerData.name || docNum?.value || '';
}
}
return '';
}
console.log('Pluggy.ai Bank Sync Plugin loaded');

View File

@@ -1,40 +0,0 @@
export const manifest = {
name: 'pluggy-bank-sync',
version: '0.0.1',
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
entry: 'index.js',
author: 'Actual Budget Team',
license: 'MIT',
routes: [
{
path: '/status',
methods: ['POST'],
auth: 'authenticated',
description: 'Check Pluggy.ai configuration status',
},
{
path: '/accounts',
methods: ['POST'],
auth: 'authenticated',
description: 'Fetch accounts from Pluggy.ai',
},
{
path: '/transactions',
methods: ['POST'],
auth: 'authenticated',
description: 'Fetch transactions from Pluggy.ai',
},
],
bankSync: {
enabled: true,
displayName: 'Pluggy.ai',
description: 'Connect your bank accounts via Pluggy.ai',
requiresAuth: true,
endpoints: {
status: '/status',
accounts: '/accounts',
transactions: '/transactions',
},
},
};
export default manifest;

View File

@@ -1,45 +0,0 @@
{
"name": "pluggy-bank-sync",
"version": "0.0.1",
"description": "Pluggy.ai bank synchronization plugin for Actual Budget",
"entry": "index.js",
"author": "Actual Budget Team",
"license": "MIT",
"routes": [
{
"path": "/status",
"methods": [
"POST"
],
"auth": "authenticated",
"description": "Check Pluggy.ai configuration status"
},
{
"path": "/accounts",
"methods": [
"POST"
],
"auth": "authenticated",
"description": "Fetch accounts from Pluggy.ai"
},
{
"path": "/transactions",
"methods": [
"POST"
],
"auth": "authenticated",
"description": "Fetch transactions from Pluggy.ai"
}
],
"bankSync": {
"enabled": true,
"displayName": "Pluggy.ai",
"description": "Connect your bank accounts via Pluggy.ai",
"requiresAuth": true,
"endpoints": {
"status": "/status",
"accounts": "/accounts",
"transactions": "/transactions"
}
}
}

View File

@@ -1,40 +0,0 @@
{
"name": "@actual-app/bank-sync-plugin-pluggy.ai",
"version": "0.0.1",
"description": "Pluggy.ai bank sync plugin for Actual Budget",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
"build:compile": "tsc",
"build:bundle": "node scripts/build-bundle.cjs",
"build:manifest": "node scripts/build-manifest.cjs",
"build:zip": "node scripts/build-zip.cjs",
"deploy": "npm run build && npm run install:plugin",
"install:plugin": "node scripts/install-plugin.cjs",
"watch": "tsc --watch",
"clean": "rm -rf dist *.zip",
"dev": "tsc --watch"
},
"keywords": [
"actual",
"plugin",
"bank-sync",
"pluggy",
"pluggyai"
],
"author": "Actual Budget",
"license": "MIT",
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"archiver": "^7.0.0",
"esbuild": "^0.24.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@actual-app/plugins-core-sync-server": "workspace:*",
"express": "^4.18.0",
"pluggy-sdk": "^0.77.0"
}
}

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env node
/**
* Build script to bundle the plugin with all dependencies
* Uses esbuild to create a single self-contained JavaScript file
*/
const esbuild = require('esbuild');
const { join } = require('path');
async function bundle() {
try {
console.log('Bundling plugin with dependencies...');
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node20',
format: 'esm',
outfile: outFile,
external: [],
minify: false,
sourcemap: false,
treeShaking: true,
});
console.log('Bundle created successfully');
console.log(`Output: dist/bundle.js`);
} catch (error) {
console.error('Failed to bundle:', error.message);
process.exit(1);
}
}
bundle();

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* Build script to convert TypeScript manifest to JSON
* This script imports the manifest.ts file and writes it as JSON to manifest.json
*/
const { writeFileSync } = require('fs');
const { join } = require('path');
// Import the manifest from the built TypeScript file
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
async function importManifest() {
// First try to import from the compiled JavaScript
try {
const manifestModule = await import('../dist/manifest.js');
return manifestModule.manifest;
} catch (error) {
console.error('Could not import compiled manifest:', error.message);
console.log(
'Make sure TypeScript is compiled first. Run: npm run build:compile',
);
process.exit(1);
}
}
async function buildManifest() {
try {
console.log('Building manifest.json...');
// Import the manifest from the compiled TypeScript
const manifest = await importManifest();
// Convert to JSON with pretty formatting
const jsonContent = JSON.stringify(manifest, null, 2);
// Write to manifest.json in the root directory
const manifestPath = join(__dirname, '..', 'manifest.json');
writeFileSync(manifestPath, jsonContent + '\n');
console.log('manifest.json created successfully');
console.log(`Package: ${manifest.name}@${manifest.version}`);
console.log(`Description: ${manifest.description}`);
console.log(`Entry point: ${manifest.entry}`);
} catch (error) {
console.error('❌ Failed to build manifest:', error.message);
process.exit(1);
}
}
buildManifest();

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env node
/**
* Build script to create a plugin distribution zip file
* Creates: {packageName}.{version}.zip containing dist/index.js and manifest.json
*/
const { createWriteStream, existsSync } = require('fs');
const { join } = require('path');
const archiver = require('archiver');
// Import package.json to get name and version
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
function importPackageJson() {
try {
const packageJson = require('../package.json');
return packageJson;
} catch (error) {
console.error('Could not import package.json:', error.message);
process.exit(1);
}
}
async function createZip() {
try {
console.log('Creating plugin distribution zip...');
// Get package info
const packageJson = importPackageJson();
const packageName = packageJson.name;
const version = packageJson.version;
// Create zip filename
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
const zipPath = join(__dirname, '..', zipFilename);
console.log(`Creating ${zipFilename}`);
// Check if required files exist
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
const manifestPath = join(__dirname, '..', 'manifest.json');
if (!existsSync(bundlePath)) {
console.error('dist/bundle.js not found. Run: npm run build:bundle');
process.exit(1);
}
if (!existsSync(manifestPath)) {
console.error('manifest.json not found. Run: npm run build:manifest');
process.exit(1);
}
// Create zip file
const output = createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 9 }, // Maximum compression
});
// Handle archive events
archive.on('error', err => {
console.error('Archive error:', err);
process.exit(1);
});
archive.on('end', () => {
const stats = archive.pointer();
console.log(`${zipFilename} created successfully`);
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
console.log(
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
);
});
// Pipe archive to file
archive.pipe(output);
// Add files to archive
archive.file(bundlePath, { name: 'index.js' });
archive.file(manifestPath, { name: 'manifest.json' });
// Finalize the archive
await archive.finalize();
} catch (error) {
console.error('Failed to create zip:', error.message);
process.exit(1);
}
}
createZip();

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const packageJson = require('../package.json');
const packageName = packageJson.name;
const version = packageJson.version;
const pluginName = packageName.replace('@', '').replace('/', '-');
const zipFileName = `${pluginName}.${version}.zip`;
// Source: built zip in package root (not in dist/)
const sourceZip = path.join(__dirname, '..', zipFileName);
// Target: sync-server plugins directory
// Go up to monorepo root, then to sync-server
const targetDir = path.join(
__dirname,
'..',
'..',
'sync-server',
'server-files',
'plugins',
);
const targetZip = path.join(targetDir, zipFileName);
console.log('📦 Installing plugin to sync-server...');
console.log(` Source: ${sourceZip}`);
console.log(` Target: ${targetZip}`);
// Check if source exists
if (!fs.existsSync(sourceZip)) {
console.error(`Error: ZIP file not found at ${sourceZip}`);
console.error(' Run "npm run build" first to create the ZIP file.');
process.exit(1);
}
// Create target directory if it doesn't exist
if (!fs.existsSync(targetDir)) {
console.log(`Creating plugins directory: ${targetDir}`);
fs.mkdirSync(targetDir, { recursive: true });
}
// Remove old versions of this plugin
try {
const files = fs.readdirSync(targetDir);
const oldVersions = files.filter(
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
);
for (const oldFile of oldVersions) {
const oldPath = path.join(targetDir, oldFile);
console.log(` Removing old version: ${oldFile}`);
fs.unlinkSync(oldPath);
}
} catch (err) {
console.warn(` Warning: Could not clean old versions: ${err.message}`);
}
// Copy the new ZIP
try {
fs.copyFileSync(sourceZip, targetZip);
console.log(` Plugin installed successfully!`);
console.log(` Location: ${targetZip}`);
console.log('');
console.log(' Restart your sync-server to load the plugin.');
} catch (err) {
console.error(` Error copying file: ${err.message}`);
process.exit(1);
}

View File

@@ -1,605 +0,0 @@
import {
attachPluginMiddleware,
saveSecret,
getSecret,
BankSyncErrorCode,
BankSyncError,
} from '@actual-app/plugins-core-sync-server';
import express, { Request, Response } from 'express';
import { PluggyClient } from 'pluggy-sdk';
// Import manifest (used during build)
import './manifest';
// Type definitions for Pluggy account structure
type PluggyConnector = {
id: number | string;
name: string;
institutionUrl?: string;
};
type PluggyItem = {
connector?: PluggyConnector;
};
type PluggyAccount = {
id: string;
name: string;
number?: string;
balance?: number;
type?: string;
itemId?: string;
item?: PluggyItem;
itemData?: PluggyItem;
updatedAt?: string;
currencyCode?: string;
owner?: string;
};
// Create Express app
const app = express();
// Use JSON middleware for parsing request bodies
app.use(express.json());
// Attach the plugin middleware to enable IPC communication with sync-server
attachPluginMiddleware(app);
// Pluggy client singleton
let pluggyClient: PluggyClient | null = null;
async function getPluggyClient(req: Request): Promise<PluggyClient> {
// Try to get credentials from secrets first
const clientIdResult = await getSecret(req, 'clientId');
const clientSecretResult = await getSecret(req, 'clientSecret');
const clientId = clientIdResult.value || req.body.clientId;
const clientSecret = clientSecretResult.value || req.body.clientSecret;
if (!clientId || !clientSecret) {
throw new Error('Pluggy.ai credentials not configured');
}
if (!pluggyClient) {
pluggyClient = new PluggyClient({
clientId,
clientSecret,
});
}
return pluggyClient;
}
/**
* GET /status
* Check if Pluggy.ai is configured
*/
app.get('/status', async (req: Request, res: Response): Promise<void> => {
try {
const clientIdResult = await getSecret(req, 'clientId');
const configured = clientIdResult.value != null;
res.json({
status: 'ok',
data: {
configured,
},
});
} catch (error) {
res.json({
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /accounts
* Fetch accounts from Pluggy.ai
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
*
* If clientId and clientSecret are provided, they will be saved as secrets
*/
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
try {
const { itemIds, clientId, clientSecret } = req.body;
// If credentials are provided in request, save them
if (clientId && clientSecret) {
await saveSecret(req, 'clientId', clientId);
await saveSecret(req, 'clientSecret', clientSecret);
}
// Get itemIds from request or from stored secrets
let itemIdsArray: string[];
if (itemIds) {
// Parse itemIds from request (can be comma-separated string or array)
if (typeof itemIds === 'string') {
itemIdsArray = itemIds.split(',').map((id: string) => id.trim());
} else if (Array.isArray(itemIds)) {
itemIdsArray = itemIds;
} else {
res.json({
status: 'error',
error: 'itemIds must be a string or array',
});
return;
}
// Save itemIds for future use
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
} else {
// Try to get itemIds from secrets
const storedItemIds = await getSecret(req, 'itemIds');
if (!storedItemIds.value) {
res.json({
status: 'error',
error:
'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
});
return;
}
itemIdsArray = storedItemIds.value
.split(',')
.map((id: string) => id.trim());
}
if (!itemIdsArray.length) {
res.json({
status: 'error',
error: 'At least one item ID is required',
});
return;
}
const client = await getPluggyClient(req);
let accounts: PluggyAccount[] = [];
// Fetch all accounts and their items with connector info
for (const itemId of itemIdsArray) {
const partial = await client.fetchAccounts(itemId);
// For each account, also fetch the item to get connector details
for (const account of partial.results) {
try {
const item = await client.fetchItem(itemId);
// Attach item info to account for transformation
(account as PluggyAccount).itemData = item;
} catch (error) {
console.error(
`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`,
error,
);
}
}
accounts = accounts.concat(partial.results as PluggyAccount[]);
}
// Transform Pluggy accounts to GenericBankSyncAccount format
const transformedAccounts = accounts.map((account: PluggyAccount) => {
const institution =
account.itemData?.connector?.name ||
account.item?.connector?.name ||
'Unknown Institution';
const connectorId =
account.itemData?.connector?.id ||
account.item?.connector?.id ||
account.itemId;
return {
account_id: account.id,
name: account.name,
institution,
balance: account.balance || 0,
mask: account.number?.substring(account.number.length - 4),
official_name: account.name,
orgDomain:
account.itemData?.connector?.institutionUrl ||
account.item?.connector?.institutionUrl ||
null,
orgId: connectorId?.toString() || null,
};
});
res.json({
status: 'ok',
data: {
accounts: transformedAccounts,
},
});
} catch (error) {
console.error('[PLUGGY ACCOUNTS] Error:', error);
// Extract Pluggy error message and code if available
let pluggyMessage = 'Unknown error';
let pluggyCode: string | number | undefined;
if (error instanceof Error) {
pluggyMessage = error.message;
// Try to parse Pluggy SDK error format from error message
// Pluggy errors often include the error details in the message
try {
// Check if error has a structured format
const errorAny = error as unknown as Record<string, unknown>;
if (errorAny.message && typeof errorAny.message === 'string') {
pluggyMessage = errorAny.message;
}
if (errorAny.code !== undefined) {
pluggyCode = errorAny.code as string | number;
}
} catch (e) {
// Ignore parse errors
}
}
const errorResponse: BankSyncError = {
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
status: 'error',
reason: pluggyMessage, // Use the Pluggy error message directly
};
// Map HTTP status codes to error types
const errorMessageLower = pluggyMessage.toLowerCase();
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
}
errorResponse.details = {
originalError: pluggyMessage,
pluggyCode: pluggyCode,
};
res.json({
status: 'ok',
data: errorResponse,
});
}
});
/**
* POST /transactions
* Fetch transactions from Pluggy.ai
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
*/
app.post(
'/transactions',
async (req: Request, res: Response): Promise<void> => {
try {
const { accountId, startDate } = req.body;
if (!accountId) {
res.json({
status: 'error',
error: 'accountId is required',
});
return;
}
const client = await getPluggyClient(req);
const transactions = await getTransactions(client, accountId, startDate);
const account = (await client.fetchAccount(accountId)) as Record<
string,
unknown
>;
let startingBalance = parseInt(
Math.round((account.balance as number) * 100).toString(),
);
if (account.type === 'CREDIT') {
startingBalance = -startingBalance;
}
const date = getDate(new Date(account.updatedAt as string));
const balances = [
{
balanceAmount: {
amount: startingBalance,
currency: account.currencyCode,
},
balanceType: 'expected',
referenceDate: date,
},
];
const all: unknown[] = [];
const booked: unknown[] = [];
const pending: unknown[] = [];
for (const trans of transactions) {
const transRecord = trans as Record<string, unknown>;
const newTrans: Record<string, unknown> = {};
newTrans.booked = !(transRecord.status === 'PENDING');
const transactionDate = new Date(transRecord.date as string);
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
continue;
}
newTrans.date = getDate(transactionDate);
newTrans.payeeName = getPayeeName(transRecord);
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
if (account.type === 'CREDIT') {
if (transRecord.amountInAccountCurrency) {
transRecord.amountInAccountCurrency =
(transRecord.amountInAccountCurrency as number) * -1;
}
transRecord.amount = (transRecord.amount as number) * -1;
}
let amountInCurrency =
(transRecord.amountInAccountCurrency as number) ??
(transRecord.amount as number);
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
newTrans.transactionAmount = {
amount: amountInCurrency,
currency: transRecord.currencyCode,
};
newTrans.transactionId = transRecord.id;
newTrans.sortOrder = transactionDate.getTime();
delete transRecord.amount;
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
if (newTrans.booked) {
booked.push(finalTrans);
} else {
pending.push(finalTrans);
}
all.push(finalTrans);
}
const sortFunction = (a: unknown, b: unknown) => {
const aRec = a as Record<string, unknown>;
const bRec = b as Record<string, unknown>;
return (bRec.sortOrder as number) - (aRec.sortOrder as number);
};
const bookedSorted = booked.sort(sortFunction);
const pendingSorted = pending.sort(sortFunction);
const allSorted = all.sort(sortFunction);
res.json({
status: 'ok',
data: {
balances,
startingBalance,
transactions: {
all: allSorted,
booked: bookedSorted,
pending: pendingSorted,
},
},
});
} catch (error) {
console.error('[PLUGGY TRANSACTIONS] Error:', error);
// Extract Pluggy error message and code if available
let pluggyMessage = 'Unknown error';
let pluggyCode: string | number | undefined;
if (error instanceof Error) {
pluggyMessage = error.message;
// Try to parse Pluggy SDK error format from error message
try {
const errorAny = error as unknown as Record<string, unknown>;
if (errorAny.message && typeof errorAny.message === 'string') {
pluggyMessage = errorAny.message;
}
if (errorAny.code !== undefined) {
pluggyCode = errorAny.code as string | number;
}
} catch (e) {
// Ignore parse errors
}
}
const errorResponse: BankSyncError = {
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
status: 'error',
reason: pluggyMessage, // Use the Pluggy error message directly
};
// Map HTTP status codes to error types
const errorMessageLower = pluggyMessage.toLowerCase();
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
}
errorResponse.details = {
originalError: pluggyMessage,
pluggyCode: pluggyCode,
};
res.json({
status: 'ok',
data: errorResponse,
});
}
},
);
// Helper functions
async function getTransactions(
client: PluggyClient,
accountId: string,
startDate: string,
): Promise<unknown[]> {
let transactions: unknown[] = [];
let result = await getTransactionsByAccountId(
client,
accountId,
startDate,
500,
1,
);
transactions = transactions.concat(result.results);
const totalPages = result.totalPages;
let currentPage = result.page;
while (currentPage !== totalPages) {
result = await getTransactionsByAccountId(
client,
accountId,
startDate,
500,
currentPage + 1,
);
transactions = transactions.concat(result.results);
currentPage = result.page;
}
return transactions;
}
async function getTransactionsByAccountId(
client: PluggyClient,
accountId: string,
startDate: string,
pageSize: number,
page: number,
): Promise<{ results: unknown[]; totalPages: number; page: number }> {
const account = (await client.fetchAccount(accountId)) as Record<
string,
unknown
>;
// Sandbox account handling
const sandboxAccount = account.owner === 'John Doe';
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
const transactions = await client.fetchTransactions(accountId, {
from: fromDate,
pageSize,
page,
});
if (sandboxAccount) {
const mappedResults = transactions.results.map(
(t: Record<string, unknown>) => ({
...t,
sandbox: true,
}),
);
transactions.results =
mappedResults as unknown as typeof transactions.results;
}
return transactions;
}
function getDate(date: Date): string {
return date.toISOString().split('T')[0];
}
function flattenObject(
obj: Record<string, unknown>,
prefix = '',
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (value === null) {
continue;
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(
result,
flattenObject(value as Record<string, unknown>, newKey),
);
} else {
result[newKey] = value;
}
}
return result;
}
function getPayeeName(trans: Record<string, unknown>): string {
const merchant = trans.merchant as Record<string, string> | undefined;
if (merchant && (merchant.name || merchant.businessName)) {
return merchant.name || merchant.businessName || '';
}
const paymentData = trans.paymentData as
| Record<string, Record<string, unknown>>
| undefined;
if (paymentData) {
const { receiver, payer } = paymentData;
if (trans.type === 'DEBIT' && receiver) {
const receiverData = receiver as Record<string, unknown>;
const docNum = receiverData.documentNumber as
| Record<string, string>
| undefined;
return (receiverData.name as string) || docNum?.value || '';
}
if (trans.type === 'CREDIT' && payer) {
const payerData = payer as Record<string, unknown>;
const docNum = payerData.documentNumber as
| Record<string, string>
| undefined;
return (payerData.name as string) || docNum?.value || '';
}
}
return '';
}
console.log('Pluggy.ai Bank Sync Plugin loaded');

View File

@@ -1,43 +0,0 @@
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
export const manifest: PluginManifest = {
name: 'pluggy-bank-sync',
version: '0.0.1',
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
entry: 'index.js',
author: 'Actual Budget Team',
license: 'MIT',
routes: [
{
path: '/status',
methods: ['POST'],
auth: 'authenticated',
description: 'Check Pluggy.ai configuration status',
},
{
path: '/accounts',
methods: ['POST'],
auth: 'authenticated',
description: 'Fetch accounts from Pluggy.ai',
},
{
path: '/transactions',
methods: ['POST'],
auth: 'authenticated',
description: 'Fetch transactions from Pluggy.ai',
},
],
bankSync: {
enabled: true,
displayName: 'Pluggy.ai',
description: 'Connect your bank accounts via Pluggy.ai',
requiresAuth: true,
endpoints: {
status: '/status',
accounts: '/accounts',
transactions: '/transactions',
},
},
};
export default manifest;

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,4 +0,0 @@
dist/
node_modules/
*.zip
*.log

View File

@@ -1,159 +0,0 @@
# SimpleFIN Bank Sync Plugin
A bank synchronization plugin for Actual Budget that connects to financial institutions via SimpleFIN.
## Overview
This plugin enables Actual Budget to sync bank account data and transactions through the SimpleFIN API. SimpleFIN provides a unified interface to connect with various financial institutions.
## Features
- Account discovery and synchronization
- Transaction import with proper categorization
- Support for pending and posted transactions
- Balance information retrieval
- Error handling for connection issues
## Installation
1. Build the plugin:
```bash
npm run build
```
2. Install the plugin to your sync-server:
```bash
npm run install:plugin
```
3. Restart your sync-server to load the plugin.
## Configuration
The plugin requires a SimpleFIN access token to authenticate with the SimpleFIN API.
### Getting a SimpleFIN Token
1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/auth/login)
2. Sign up for an account
3. Connect your financial institutions
4. Generate an access token
### Plugin Setup
Once the plugin is installed, configure it in Actual Budget by providing your SimpleFIN token when prompted during the bank connection setup.
## API Endpoints
### POST /status
Check if the plugin is configured with valid credentials.
**Response:**
```json
{
"status": "ok",
"data": {
"configured": true
}
}
```
### POST /accounts
Fetch available accounts from connected financial institutions.
**Request Body:**
```json
{
"token": "your-simplefin-token" // optional, will be saved if provided
}
```
**Response:**
```json
{
"status": "ok",
"data": {
"accounts": [
{
"account_id": "123456789",
"name": "Checking Account",
"institution": "Bank Name",
"balance": 1234.56,
"mask": "6789",
"official_name": "Premium Checking",
"orgDomain": "bank.com",
"orgId": "BANK123"
}
]
}
}
```
### POST /transactions
Fetch transactions for specific accounts within a date range.
**Request Body:**
```json
{
"accountId": "123456789",
"startDate": "2024-01-01"
}
```
**Response:**
```json
{
"status": "ok",
"data": {
"balances": [
{
"balanceAmount": {
"amount": "1234.56",
"currency": "USD"
},
"balanceType": "expected",
"referenceDate": "2024-01-15"
}
],
"startingBalance": 123456,
"transactions": {
"all": [...],
"booked": [...],
"pending": [...]
}
}
}
```
## Error Handling
The plugin provides detailed error messages for various failure scenarios:
- `INVALID_ACCESS_TOKEN`: Invalid or expired SimpleFIN token
- `SERVER_DOWN`: Communication issues with SimpleFIN
- `ACCOUNT_MISSING`: Specified account not found
- `ACCOUNT_NEEDS_ATTENTION`: Account requires attention on SimpleFIN Bridge
## Development
### Building
```bash
npm run build # Full build (compile + bundle + manifest + zip)
npm run build:compile # TypeScript compilation only
npm run build:bundle # Bundle with dependencies
npm run build:manifest # Generate manifest.json
npm run build:zip # Create distribution zip
```
### Testing
The plugin integrates with Actual Budget's existing test infrastructure. Run tests from the monorepo root:
```bash
yarn test
```
## License
MIT

View File

@@ -1,45 +0,0 @@
{
"name": "simplefin-bank-sync",
"version": "0.0.1",
"description": "SimpleFIN bank synchronization plugin for Actual Budget",
"entry": "index.js",
"author": "Actual Budget Team",
"license": "MIT",
"routes": [
{
"path": "/status",
"methods": [
"POST"
],
"auth": "authenticated",
"description": "Check SimpleFIN configuration status"
},
{
"path": "/accounts",
"methods": [
"POST"
],
"auth": "authenticated",
"description": "Fetch accounts from SimpleFIN"
},
{
"path": "/transactions",
"methods": [
"POST"
],
"auth": "authenticated",
"description": "Fetch transactions from SimpleFIN"
}
],
"bankSync": {
"enabled": true,
"displayName": "SimpleFIN",
"description": "Connect your bank accounts via SimpleFIN",
"requiresAuth": true,
"endpoints": {
"status": "/status",
"accounts": "/accounts",
"transactions": "/transactions"
}
}
}

View File

@@ -1,39 +0,0 @@
{
"name": "@actual-app/bank-sync-plugin-simplefin",
"version": "0.0.1",
"description": "SimpleFIN bank sync plugin for Actual Budget",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
"build:compile": "tsc",
"build:bundle": "node scripts/build-bundle.cjs",
"build:manifest": "node scripts/build-manifest.cjs",
"build:zip": "node scripts/build-zip.cjs",
"deploy": "npm run build && npm run install:plugin",
"install:plugin": "node scripts/install-plugin.cjs",
"watch": "tsc --watch",
"clean": "rm -rf dist *.zip",
"dev": "tsc --watch"
},
"keywords": [
"actual",
"plugin",
"bank-sync",
"simplefin"
],
"author": "Actual Budget",
"license": "MIT",
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"archiver": "^7.0.0",
"esbuild": "^0.24.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@actual-app/plugins-core-sync-server": "workspace:*",
"axios": "^1.6.0",
"express": "^4.18.0"
}
}

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env node
/**
* Build script to bundle the plugin with all dependencies
* Uses esbuild to create a single self-contained JavaScript file
*/
const esbuild = require('esbuild');
const { join } = require('path');
async function bundle() {
try {
console.log('Bundling plugin with dependencies...');
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node20',
format: 'esm',
outfile: outFile,
external: ['express', 'axios'],
minify: false,
sourcemap: false,
treeShaking: true,
});
console.log('Bundle created successfully');
console.log(`Output: dist/bundle.js`);
} catch (error) {
console.error('Failed to bundle:', error.message);
process.exit(1);
}
}
bundle();

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* Build script to convert TypeScript manifest to JSON
* This script imports the manifest.ts file and writes it as JSON to manifest.json
*/
const { writeFileSync } = require('fs');
const { join } = require('path');
// Import the manifest from the built TypeScript file
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
async function importManifest() {
// First try to import from the compiled JavaScript
try {
const manifestModule = await import('../dist/manifest.js');
return manifestModule.manifest;
} catch (error) {
console.error('Could not import compiled manifest:', error.message);
console.log(
'Make sure TypeScript is compiled first. Run: npm run build:compile',
);
process.exit(1);
}
}
async function buildManifest() {
try {
console.log('Building manifest.json...');
// Import the manifest from the compiled TypeScript
const manifest = await importManifest();
// Convert to JSON with pretty formatting
const jsonContent = JSON.stringify(manifest, null, 2);
// Write to manifest.json in the root directory
const manifestPath = join(__dirname, '..', 'manifest.json');
writeFileSync(manifestPath, jsonContent + '\n');
console.log('manifest.json created successfully');
console.log(`Package: ${manifest.name}@${manifest.version}`);
console.log(`Description: ${manifest.description}`);
console.log(`Entry point: ${manifest.entry}`);
} catch (error) {
console.error('❌ Failed to build manifest:', error.message);
process.exit(1);
}
}
buildManifest();

View File

@@ -1,104 +0,0 @@
#!/usr/bin/env node
/**
* Build script to create a plugin distribution zip file
* Creates: {packageName}.{version}.zip containing dist/index.js, manifest.json, and package.json
*/
const { createWriteStream, existsSync } = require('fs');
const { join } = require('path');
const archiver = require('archiver');
// Import package.json to get name and version
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
function importPackageJson() {
try {
const packageJson = require('../package.json');
return packageJson;
} catch (error) {
console.error('Could not import package.json:', error.message);
process.exit(1);
}
}
async function createZip() {
try {
console.log('Creating plugin distribution zip...');
// Get package info
const packageJson = importPackageJson();
const packageName = packageJson.name;
const version = packageJson.version;
// Create zip filename
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
const zipPath = join(__dirname, '..', zipFilename);
console.log(`Creating ${zipFilename}`);
// Check if required files exist
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
const manifestPath = join(__dirname, '..', 'manifest.json');
if (!existsSync(bundlePath)) {
console.error('dist/bundle.js not found. Run: npm run build:bundle');
process.exit(1);
}
if (!existsSync(manifestPath)) {
console.error('manifest.json not found. Run: npm run build:manifest');
process.exit(1);
}
// Create zip file
const output = createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 9 }, // Maximum compression
});
// Handle archive events
archive.on('error', err => {
console.error('Archive error:', err);
process.exit(1);
});
archive.on('end', () => {
const stats = archive.pointer();
console.log(`${zipFilename} created successfully`);
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
console.log(
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
);
});
// Pipe archive to file
archive.pipe(output);
// Create package.json for the plugin with runtime dependencies
const pluginPackageJson = {
type: 'module',
dependencies: {
express: packageJson.dependencies.express,
axios: packageJson.dependencies.axios,
},
};
const pluginPackageJsonContent = JSON.stringify(
pluginPackageJson,
null,
2,
);
// Add files to archive
archive.file(bundlePath, { name: 'index.js' });
archive.file(manifestPath, { name: 'manifest.json' });
archive.append(pluginPackageJsonContent, { name: 'package.json' });
// Finalize the archive
await archive.finalize();
} catch (error) {
console.error('Failed to create zip:', error.message);
process.exit(1);
}
}
createZip();

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const packageJson = require('../package.json');
const packageName = packageJson.name;
const version = packageJson.version;
const pluginName = packageName.replace('@', '').replace('/', '-');
const zipFileName = `${pluginName}.${version}.zip`;
// Source: built zip in package root (not in dist/)
const sourceZip = path.join(__dirname, '..', zipFileName);
// Target: sync-server plugins directory
// Go up to monorepo root, then to sync-server
const targetDir = path.join(
__dirname,
'..',
'..',
'sync-server',
'server-files',
'plugins',
);
const targetZip = path.join(targetDir, zipFileName);
console.log('📦 Installing plugin to sync-server...');
console.log(` Source: ${sourceZip}`);
console.log(` Target: ${targetZip}`);
// Check if source exists
if (!fs.existsSync(sourceZip)) {
console.error(`Error: ZIP file not found at ${sourceZip}`);
console.error(' Run "npm run build" first to create the ZIP file.');
process.exit(1);
}
// Create target directory if it doesn't exist
if (!fs.existsSync(targetDir)) {
console.log(`Creating plugins directory: ${targetDir}`);
fs.mkdirSync(targetDir, { recursive: true });
}
// Remove old versions of this plugin
try {
const files = fs.readdirSync(targetDir);
const oldVersions = files.filter(
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
);
for (const oldFile of oldVersions) {
const oldPath = path.join(targetDir, oldFile);
console.log(` Removing old version: ${oldFile}`);
fs.unlinkSync(oldPath);
}
} catch (err) {
console.warn(` Warning: Could not clean old versions: ${err.message}`);
}
// Copy the new ZIP
try {
fs.copyFileSync(sourceZip, targetZip);
console.log(` Plugin installed successfully!`);
console.log(` Location: ${targetZip}`);
console.log('');
console.log(' Restart your sync-server to load the plugin.');
} catch (err) {
console.error(` Error copying file: ${err.message}`);
process.exit(1);
}

View File

@@ -1,562 +0,0 @@
import {
attachPluginMiddleware,
saveSecret,
getSecret,
BankSyncErrorCode,
BankSyncError,
} from '@actual-app/plugins-core-sync-server';
import express, { Request, Response } from 'express';
import axios from 'axios';
// Import manifest (used during build)
import './manifest';
// Type definitions for SimpleFIN account structure
type SimpleFINAccount = {
id: string;
name: string;
balance: string;
currency: string;
'balance-date': number;
org: {
name: string;
domain?: string;
};
transactions: SimpleFINTransaction[];
};
type SimpleFINTransaction = {
id: string;
payee: string;
description: string;
amount: string;
transacted_at?: number;
posted?: number;
pending?: boolean | number;
};
type SimpleFINResponse = {
accounts: SimpleFINAccount[];
errors: string[];
sferrors: string[];
hasError: boolean;
accountErrors?: Record<string, any[]>;
};
type ParsedAccessKey = {
baseUrl: string;
username: string;
password: string;
};
// Create Express app
const app = express();
// Use JSON middleware for parsing request bodies
app.use(express.json());
// Attach the plugin middleware to enable IPC communication with sync-server
attachPluginMiddleware(app);
/**
* POST /status
* Check if SimpleFIN is configured
*/
app.post('/status', async (req: Request, res: Response): Promise<void> => {
try {
const tokenResult = await getSecret(req, 'simplefin_token');
const configured = tokenResult.value != null && tokenResult.value !== 'Forbidden';
res.json({
status: 'ok',
data: {
configured,
},
});
} catch (error) {
res.json({
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /accounts
* Fetch accounts from SimpleFIN
* Body: { token?: string }
*
* If token is provided, it will be saved as a secret
*/
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
try {
const { token } = req.body;
// If token is provided in request, save it
if (token) {
await saveSecret(req, 'simplefin_token', token);
}
let accessKey: string | null = null;
try {
const tokenResult = await getSecret(req, 'simplefin_token');
const storedToken = tokenResult.value;
if (storedToken == null || storedToken === 'Forbidden') {
throw new Error('No token');
} else {
accessKey = await getAccessKey(storedToken);
await saveSecret(req, 'simplefin_accessKey', accessKey);
if (accessKey == null || accessKey === 'Forbidden') {
throw new Error('No access key');
}
}
} catch {
res.json({
status: 'ok',
data: {
error_type: 'INVALID_ACCESS_TOKEN',
error_code: 'INVALID_ACCESS_TOKEN',
status: 'rejected',
reason:
'Invalid SimpleFIN access token. Reset the token and re-link any broken accounts.',
},
});
return;
}
try {
const accounts = await getAccounts(accessKey, null, null, null, true);
// Transform SimpleFIN accounts to GenericBankSyncAccount format
const transformedAccounts = accounts.accounts.map((account: SimpleFINAccount) => ({
account_id: account.id,
name: account.name,
institution: account.org.name,
balance: parseFloat(account.balance.replace('.', '')) / 100,
mask: account.id.substring(account.id.length - 4),
official_name: account.name,
orgDomain: account.org.domain || null,
orgId: account.org.name,
}));
res.json({
status: 'ok',
data: {
accounts: transformedAccounts,
},
});
} catch (e) {
console.error('[SIMPLEFIN ACCOUNTS] Error:', e);
const errorResponse: BankSyncError = {
error_type: BankSyncErrorCode.SERVER_ERROR,
error_code: BankSyncErrorCode.SERVER_ERROR,
status: 'error',
reason: 'There was an error communicating with SimpleFIN.',
};
if (e instanceof Error) {
const errorMessage = e.message.toLowerCase();
if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
errorResponse.error_code = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
errorResponse.reason = 'Invalid SimpleFIN access token. Please reconfigure your connection.';
} else if (errorMessage.includes('401') || errorMessage.includes('unauthorized')) {
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.reason = 'Unauthorized access to SimpleFIN. Please check your credentials.';
} else if (errorMessage.includes('network') || errorMessage.includes('econnrefused') || errorMessage.includes('enotfound')) {
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.reason = 'Network error communicating with SimpleFIN. Please check your connection.';
}
errorResponse.details = { originalError: e.message };
}
res.json({
status: 'ok',
data: errorResponse,
});
return;
}
} catch (error) {
res.json({
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /transactions
* Fetch transactions from SimpleFIN
* Body: { accountId: string, startDate: string, token?: string }
*/
app.post('/transactions', async (req: Request, res: Response): Promise<void> => {
try {
const { accountId, startDate } = req.body || {};
if (!accountId) {
res.json({
status: 'error',
error: 'accountId is required',
});
return;
}
const accessKeyResult = await getSecret(req, 'simplefin_accessKey');
if (accessKeyResult.value == null || accessKeyResult.value === 'Forbidden') {
res.json({
status: 'ok',
data: {
error_type: 'INVALID_ACCESS_TOKEN',
error_code: 'INVALID_ACCESS_TOKEN',
status: 'rejected',
reason:
'Invalid SimpleFIN access token. Reset the token and re-link any broken accounts.',
},
});
return;
}
if (Array.isArray(accountId) !== Array.isArray(startDate)) {
console.log({ accountId, startDate });
res.json({
status: 'error',
error: 'accountId and startDate must either both be arrays or both be strings',
});
return;
}
if (Array.isArray(accountId) && accountId.length !== startDate.length) {
console.log({ accountId, startDate });
res.json({
status: 'error',
error: 'accountId and startDate arrays must be the same length',
});
return;
}
const earliestStartDate = Array.isArray(startDate)
? startDate.reduce((a, b) => (a < b ? a : b))
: startDate;
let results: SimpleFINResponse;
try {
results = await getTransactions(
accessKeyResult.value,
Array.isArray(accountId) ? accountId : [accountId],
new Date(earliestStartDate),
);
} catch (e) {
console.error('[SIMPLEFIN TRANSACTIONS] Error:', e);
const errorResponse: BankSyncError = {
error_type: BankSyncErrorCode.SERVER_ERROR,
error_code: BankSyncErrorCode.SERVER_ERROR,
status: 'error',
reason: 'There was an error communicating with SimpleFIN.',
};
if (e instanceof Error) {
const errorMessage = e.message.toLowerCase();
if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
errorResponse.error_type = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
errorResponse.error_code = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
errorResponse.reason = 'Invalid SimpleFIN access token. Please reconfigure your connection.';
} else if (errorMessage.includes('401') || errorMessage.includes('unauthorized')) {
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
errorResponse.reason = 'Unauthorized access to SimpleFIN. Please check your credentials.';
} else if (errorMessage.includes('404') || errorMessage.includes('not found')) {
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
errorResponse.reason = 'Account not found in SimpleFIN. Please check your account configuration.';
} else if (errorMessage.includes('network') || errorMessage.includes('econnrefused') || errorMessage.includes('enotfound')) {
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
errorResponse.reason = 'Network error communicating with SimpleFIN. Please check your connection.';
}
errorResponse.details = { originalError: e.message };
}
res.json({
status: 'ok',
data: errorResponse,
});
return;
}
let response: any = {};
if (Array.isArray(accountId)) {
for (let i = 0; i < accountId.length; i++) {
const id = accountId[i];
response[id] = getAccountResponse(results, id, new Date(startDate[i]));
}
} else {
response = getAccountResponse(results, accountId, new Date(startDate));
}
if (results.hasError) {
res.json({
status: 'ok',
data: !Array.isArray(accountId)
? (results.accountErrors?.[accountId]?.[0] || results.errors[0])
: {
...response,
errors: results.accountErrors || results.errors,
},
});
return;
}
res.json({
status: 'ok',
data: response,
});
} catch (error) {
res.json({
status: 'ok',
data: {
error: error instanceof Error ? error.message : 'Unknown error',
},
});
}
});
// Helper functions
function logAccountError(results: SimpleFINResponse, accountId: string, data: any) {
// For account-specific errors, we store them in the results object for later retrieval
if (!results.accountErrors) {
results.accountErrors = {};
}
const errors = results.accountErrors[accountId] || [];
errors.push(data);
results.accountErrors[accountId] = errors;
results.hasError = true;
}
function getAccountResponse(results: SimpleFINResponse, accountId: string, startDate: Date): any {
const account = !results?.accounts ? undefined : results.accounts.find(a => a.id === accountId);
if (!account) {
console.log(
`The account "${accountId}" was not found. Here were the accounts returned:`,
);
if (results?.accounts) {
results.accounts.forEach(a => console.log(`${a.id} - ${a.org.name}`));
}
logAccountError(results, accountId, {
error_type: 'ACCOUNT_MISSING',
error_code: 'ACCOUNT_MISSING',
reason: `The account "${accountId}" was not found. Try unlinking and relinking the account.`,
});
return;
}
const needsAttention = results.sferrors.find(e =>
e.startsWith(`Connection to ${account.org.name} may need attention`),
);
if (needsAttention) {
logAccountError(results, accountId, {
error_type: 'ACCOUNT_NEEDS_ATTENTION',
error_code: 'ACCOUNT_NEEDS_ATTENTION',
reason:
'The account needs your attention at <a href="https://bridge.simplefin.org/auth/login">SimpleFIN</a>.',
});
}
const startingBalance = parseInt(account.balance.replace('.', ''));
const date = getDate(new Date(account['balance-date'] * 1000));
const balances = [
{
balanceAmount: {
amount: account.balance,
currency: account.currency,
},
balanceType: 'expected',
referenceDate: date,
},
{
balanceAmount: {
amount: account.balance,
currency: account.currency,
},
balanceType: 'interimAvailable',
referenceDate: date,
},
];
const all: any[] = [];
const booked: any[] = [];
const pending: any[] = [];
for (const trans of account.transactions) {
const newTrans: any = {};
let dateToUse = 0;
if (trans.pending ?? trans.posted === 0) {
newTrans.booked = false;
dateToUse = trans.transacted_at || 0;
} else {
newTrans.booked = true;
dateToUse = trans.posted || 0;
}
const transactionDate = new Date(dateToUse * 1000);
if (transactionDate < startDate) {
continue;
}
newTrans.sortOrder = dateToUse;
newTrans.date = getDate(transactionDate);
newTrans.payeeName = trans.payee;
newTrans.notes = trans.description;
newTrans.transactionAmount = { amount: trans.amount, currency: 'USD' };
newTrans.transactionId = trans.id;
newTrans.valueDate = newTrans.bookingDate;
if (trans.transacted_at) {
newTrans.transactedDate = getDate(new Date(trans.transacted_at * 1000));
}
if (trans.posted) {
newTrans.postedDate = getDate(new Date(trans.posted * 1000));
}
if (newTrans.booked) {
booked.push(newTrans);
} else {
pending.push(newTrans);
}
all.push(newTrans);
}
const sortFunction = (a: any, b: any) => b.sortOrder - a.sortOrder;
const bookedSorted = booked.sort(sortFunction);
const pendingSorted = pending.sort(sortFunction);
const allSorted = all.sort(sortFunction);
return {
balances,
startingBalance,
transactions: {
all: allSorted,
booked: bookedSorted,
pending: pendingSorted,
},
};
}
function parseAccessKey(accessKey: string): ParsedAccessKey {
if (!accessKey || !accessKey.match(/^.*\/\/.*:.*@.*$/)) {
console.log(`Invalid SimpleFIN access key: ${accessKey}`);
throw new Error(`Invalid access key`);
}
const [scheme, rest] = accessKey.split('//');
const [auth, restAfterAuth] = rest.split('@');
const [username, password] = auth.split(':');
const baseUrl = `${scheme}//${restAfterAuth}`;
return {
baseUrl,
username,
password,
};
}
async function getAccessKey(base64Token: string): Promise<string> {
const token = Buffer.from(base64Token, 'base64').toString();
const response = await axios.post(token, undefined, {
headers: { 'Content-Length': 0 },
});
return response.data;
}
async function getTransactions(
accessKey: string,
accounts: string[],
startDate: Date,
endDate?: Date,
): Promise<SimpleFINResponse> {
const now = new Date();
startDate = startDate || new Date(now.getFullYear(), now.getMonth(), 1);
endDate = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 1);
console.log(`${getDate(startDate)} - ${getDate(endDate)}`);
return await getAccounts(accessKey, accounts, startDate, endDate);
}
function getDate(date: Date): string {
return date.toISOString().split('T')[0];
}
function normalizeDate(date: Date): number {
return (date.valueOf() - date.getTimezoneOffset() * 60 * 1000) / 1000;
}
async function getAccounts(
accessKey: string,
accounts?: string[] | null,
startDate?: Date | null,
endDate?: Date | null,
noTransactions = false,
): Promise<SimpleFINResponse> {
const sfin = parseAccessKey(accessKey);
const headers = {
Authorization: `Basic ${Buffer.from(
`${sfin.username}:${sfin.password}`,
).toString('base64')}`,
};
const params = new URLSearchParams();
if (!noTransactions) {
if (startDate) {
params.append('start-date', normalizeDate(startDate).toString());
}
if (endDate) {
params.append('end-date', normalizeDate(endDate).toString());
}
params.append('pending', '1');
} else {
params.append('balances-only', '1');
}
if (accounts) {
for (const id of accounts) {
params.append('account', id);
}
}
const url = new URL(`${sfin.baseUrl}/accounts`);
url.search = params.toString();
const response = await axios.get(url.toString(), {
headers,
maxRedirects: 5,
});
if (response.status === 403) {
throw new Error('Forbidden');
}
// axios automatically parses JSON, so response.data is already an object
const results: SimpleFINResponse = response.data as SimpleFINResponse;
results.sferrors = results.errors;
results.hasError = false;
results.errors = [];
results.accountErrors = {};
return results;
}
console.log('SimpleFIN Bank Sync Plugin loaded');

View File

@@ -1,43 +0,0 @@
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
export const manifest: PluginManifest = {
name: 'simplefin-bank-sync',
version: '0.0.1',
description: 'SimpleFIN bank synchronization plugin for Actual Budget',
entry: 'index.js',
author: 'Actual Budget Team',
license: 'MIT',
routes: [
{
path: '/status',
methods: ['POST'],
auth: 'authenticated',
description: 'Check SimpleFIN configuration status',
},
{
path: '/accounts',
methods: ['POST'],
auth: 'authenticated',
description: 'Fetch accounts from SimpleFIN',
},
{
path: '/transactions',
methods: ['POST'],
auth: 'authenticated',
description: 'Fetch transactions from SimpleFIN',
},
],
bankSync: {
enabled: true,
displayName: 'SimpleFIN',
description: 'Connect your bank accounts via SimpleFIN',
requiresAuth: true,
endpoints: {
status: '/status',
accounts: '/accounts',
transactions: '/transactions',
},
},
};
export default manifest;

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -6,6 +6,6 @@
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest"
"test": "vitest --run"
}
}

View File

@@ -5,5 +5,10 @@ export default defineConfig({
globals: true,
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
environment: 'node',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@@ -13,7 +13,7 @@
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.0",
"@types/react": "^19.2.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"vitest": "^3.2.4"
@@ -54,6 +54,6 @@
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
"test": "npm-run-all -cp 'test:*'",
"test:web": "ENV=web vitest -c vitest.web.config.ts"
"test:web": "ENV=web vitest --run -c vitest.web.config.ts"
}
}

View File

@@ -4,6 +4,7 @@ import {
isValidElement,
type ReactElement,
Ref,
RefObject,
useEffect,
useRef,
} from 'react';
@@ -11,15 +12,20 @@ import {
type InitialFocusProps<T extends HTMLElement> = {
/**
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
children:
| ReactElement<{ ref: Ref<T> }>
| ((ref: RefObject<T | null>) => ReactElement);
};
/**
* InitialFocus sets focus on its child element
* when it mounts.
* @param {Object} props - The component props.
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
* @param {ReactElement | function} children - A single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
export function InitialFocus<T extends HTMLElement = HTMLElement>({
children,

View File

@@ -21,6 +21,12 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
include: ['src/**/*.web.test.(js|jsx|ts|tsx)'],
poolOptions: {
threads: {
maxThreads: 2,
minThreads: 1,
},
},
},
resolve: {
alias: [

View File

@@ -12,7 +12,7 @@
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --globals"
"test": "vitest --run --globals"
},
"dependencies": {
"google-protobuf": "^3.21.4",

View File

@@ -31,3 +31,6 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

View File

@@ -65,10 +65,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
# Use the ip and port noted earlier

View File

@@ -9,7 +9,6 @@ rm -fr build
export IS_GENERIC_BROWSER=1
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH=`ls "$ROOT"/../service-worker/plugin-sw.*.js | sed 's/.*plugin-sw\.\(.*\)\.js/\1/'`
yarn build

View File

@@ -6,6 +6,5 @@ cd "$ROOT/.."
export IS_GENERIC_BROWSER=1
export PORT=3001
export REACT_APP_BACKEND_WORKER_HASH="dev"
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH="dev"
yarn start

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -123,11 +123,14 @@ test.describe('Accounts', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
if (screenshot) await expect(page).toMatchThemeScreenshots();
const importButton = accountPage.page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.waitFor({ state: 'visible' });
if (screenshot) await expect(page).toMatchThemeScreenshots();
await importButton.click();
await expect(importButton).not.toBeVisible();
@@ -146,12 +149,14 @@ test.describe('Accounts', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
await expect(page).toMatchThemeScreenshots();
const importButton = accountPage.page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.waitFor({ state: 'visible' });
await expect(page).toMatchThemeScreenshots();
await expect(importButton).toBeDisabled();
await expect(await importButton.innerText()).toMatch(
/Import 0 transactions/,

View File

@@ -0,0 +1,64 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { type MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
import { MobileNavigation } from './page-models/mobile-navigation';
test.describe('Mobile Bank Sync', () => {
let page: Page;
let navigation: MobileNavigation;
let bankSyncPage: MobileBankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new MobileNavigation(page);
configurationPage = new ConfigurationPage(page);
await page.setViewportSize({
width: 350,
height: 600,
});
await page.goto('/');
await configurationPage.createTestFile();
bankSyncPage = await navigation.goToBankSyncPage();
});
test.afterEach(async () => {
await page.close();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(
page.getByRole('heading', { name: 'Bank Sync' }),
).toBeVisible();
await expect(bankSyncPage.searchBox).toBeVisible();
await expect(bankSyncPage.searchBox).toHaveAttribute(
'placeholder',
'Filter accounts…',
);
await expect(page).toMatchThemeScreenshots();
});
test('searches for accounts', async () => {
await bankSyncPage.searchFor('Checking');
await expect(bankSyncPage.searchBox).toHaveValue('Checking');
await expect(page).toMatchThemeScreenshots();
});
test('page handles empty state gracefully', async () => {
await bankSyncPage.searchFor('NonExistentAccount123456789');
const emptyMessage = page.getByText(/No accounts found/);
await expect(emptyMessage).toBeVisible();
await expect(page).toMatchThemeScreenshots();
});
});

View File

@@ -0,0 +1,35 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { type BankSyncPage } from './page-models/bank-sync-page';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';
test.describe('Bank Sync', () => {
let page: Page;
let navigation: Navigation;
let bankSyncPage: BankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
navigation = new Navigation(page);
configurationPage = new ConfigurationPage(page);
await page.goto('/');
await configurationPage.createTestFile();
});
test.afterAll(async () => {
await page.close();
});
test.beforeEach(async () => {
bankSyncPage = await navigation.goToBankSyncPage();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(page).toMatchThemeScreenshots();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,13 @@
import { type Page } from '@playwright/test';
export class BankSyncPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async waitToLoad() {
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
}
}

View File

@@ -29,10 +29,14 @@ export class CustomReportPage {
async selectMode(mode: 'total' | 'time') {
switch (mode) {
case 'total':
await this.pageContent.getByRole('button', { name: 'Total' }).click();
await this.pageContent
.getByRole('button', { name: 'Total', exact: true })
.click();
break;
case 'time':
await this.pageContent.getByRole('button', { name: 'Time' }).click();
await this.pageContent
.getByRole('button', { name: 'Time', exact: true })
.click();
break;
default:
throw new Error(`Unrecognized mode: ${mode}`);

View File

@@ -0,0 +1,28 @@
import { type Locator, type Page } from '@playwright/test';
export class MobileBankSyncPage {
readonly page: Page;
readonly searchBox: Locator;
readonly accountsList: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder(/Filter accounts/i);
this.accountsList = page.getByRole('main');
}
async waitFor(options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
timeout?: number;
}) {
await this.accountsList.waitFor(options);
}
async waitToLoad() {
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
}
async searchFor(term: string) {
await this.searchBox.fill(term);
}
}

View File

@@ -2,7 +2,9 @@ import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBankSyncPage } from './mobile-bank-sync-page';
import { MobileBudgetPage } from './mobile-budget-page';
import { MobilePayeesPage } from './mobile-payees-page';
import { MobileReportsPage } from './mobile-reports-page';
import { MobileRulesPage } from './mobile-rules-page';
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
@@ -14,6 +16,7 @@ const NAV_LINKS_HIDDEN_BY_DEFAULT = [
'Schedules',
'Payees',
'Rules',
'Bank Sync',
'Settings',
];
const ROUTES_BY_PAGE = {
@@ -21,7 +24,9 @@ const ROUTES_BY_PAGE = {
Accounts: '/accounts',
Transaction: '/transactions/new',
Reports: '/reports',
Payees: '/payees',
Rules: '/rules',
'Bank Sync': '/bank-sync',
Settings: '/settings',
};
@@ -166,6 +171,13 @@ export class MobileNavigation {
);
}
async goToPayeesPage() {
return await this.navigateToPage(
'Payees',
() => new MobilePayeesPage(this.page),
);
}
async goToRulesPage() {
return await this.navigateToPage(
'Rules',
@@ -173,6 +185,13 @@ export class MobileNavigation {
);
}
async goToBankSyncPage() {
return await this.navigateToPage(
'Bank Sync',
() => new MobileBankSyncPage(this.page),
);
}
async goToSettingsPage() {
return await this.navigateToPage(
'Settings',

View File

@@ -0,0 +1,75 @@
import { type Locator, type Page } from '@playwright/test';
export class MobilePayeesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly payeesList: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter payees…');
this.payeesList = page.getByRole('grid', { name: 'Payees' });
this.emptyMessage = page.getByText('No payees found.');
this.loadingIndicator = page.getByTestId('animated-loading');
}
async waitFor(options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
timeout?: number;
}) {
await this.payeesList.waitFor(options);
}
/**
* Search for payees using the search box
*/
async searchFor(text: string) {
await this.searchBox.fill(text);
}
/**
* Clear the search box
*/
async clearSearch() {
await this.searchBox.fill('');
}
/**
* Get the nth payee item (0-based index)
*/
getNthPayee(index: number) {
return this.getAllPayees().nth(index);
}
/**
* Get all visible payee items
*/
getAllPayees() {
return this.payeesList.getByRole('gridcell');
}
/**
* Click on a payee to view/edit rules
*/
async clickPayee(index: number) {
const payee = this.getNthPayee(index);
await payee.click();
}
/**
* Get the number of visible payees
*/
async getPayeeCount() {
const payees = this.getAllPayees();
return await payees.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -73,28 +73,6 @@ export class MobileRulesPage {
return await rules.count();
}
/**
* Check if the search bar has a border
*/
async hasSearchBarBorder() {
const searchContainer = this.searchBox.locator('..');
const borderStyle = await searchContainer.evaluate(el => {
const style = window.getComputedStyle(el);
return style.borderBottomWidth;
});
return borderStyle === '2px';
}
/**
* Get the background color of the search box
*/
async getSearchBackgroundColor() {
return await this.searchBox.evaluate(el => {
const style = window.getComputedStyle(el);
return style.backgroundColor;
});
}
/**
* Check if a rule contains specific text
*/

View File

@@ -1,6 +1,7 @@
import { type Page } from '@playwright/test';
import { AccountPage } from './account-page';
import { BankSyncPage } from './bank-sync-page';
import { PayeesPage } from './payees-page';
import { ReportsPage } from './reports-page';
import { RulesPage } from './rules-page';
@@ -66,6 +67,19 @@ export class Navigation {
return new PayeesPage(this.page);
}
async goToBankSyncPage() {
const bankSyncLink = this.page.getByRole('link', { name: 'Bank Sync' });
// Expand the "more" menu only if it is not already expanded
if (!(await bankSyncLink.isVisible())) {
await this.page.getByRole('button', { name: 'More' }).click();
}
await bankSyncLink.click();
return new BankSyncPage(this.page);
}
async goToSettingsPage() {
const settingsLink = this.page.getByRole('link', { name: 'Settings' });

View File

@@ -0,0 +1,112 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { MobileNavigation } from './page-models/mobile-navigation';
import { type MobilePayeesPage } from './page-models/mobile-payees-page';
test.describe('Mobile Payees', () => {
let page: Page;
let navigation: MobileNavigation;
let payeesPage: MobilePayeesPage;
let configurationPage: ConfigurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new MobileNavigation(page);
configurationPage = new ConfigurationPage(page);
// Set mobile viewport
await page.setViewportSize({
width: 350,
height: 600,
});
await page.goto('/');
await configurationPage.createTestFile();
// Navigate to payees page and wait for it to load
payeesPage = await navigation.goToPayeesPage();
});
test.afterEach(async () => {
await page.close();
});
test('checks the page visuals', async () => {
await payeesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(page.getByRole('heading', { name: 'Payees' })).toBeVisible();
// Check that the search box is present with proper placeholder
await expect(payeesPage.searchBox).toBeVisible();
await expect(payeesPage.searchBox).toHaveAttribute(
'placeholder',
'Filter payees…',
);
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
await expect(page).toMatchThemeScreenshots();
});
test('filters out unrelated payees', async () => {
await payeesPage.searchFor('asdfasdf-nonsense');
// Get the text 'No payees found.' from the page
const noPayeesMessage = page.getByText('No payees found.');
// Assert it is visible
await expect(noPayeesMessage).toBeVisible();
await expect(page).toMatchThemeScreenshots();
});
test('clicking on a payee opens rule creation form', async () => {
await payeesPage.waitForLoadingToComplete();
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
await payeesPage.clickPayee(0);
// Should navigate to rules page for creating a new rule
await expect(page).toHaveURL(/\/rules/);
await expect(page).toMatchThemeScreenshots();
});
test('page handles empty state gracefully', async () => {
// Search for something that won't match to get empty state
await payeesPage.searchFor('NonExistentPayee123456789');
await page.waitForTimeout(500);
// Check that empty message is shown
const emptyMessage = page.getByText('No payees found.');
await expect(emptyMessage).toBeVisible();
// Check that no payee items are visible
const payees = payeesPage.getAllPayees();
await expect(payees).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
});
test('search functionality works correctly', async () => {
await payeesPage.waitForLoadingToComplete();
// Test searching for a specific payee
await payeesPage.searchFor('Fast Internet');
// Should show at least one result
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
await expect(page).toMatchThemeScreenshots();
// Clear search
await payeesPage.clearSearch();
// Should show all payees again
const allPayeeCount = await payeesPage.getPayeeCount();
expect(allPayeeCount).toBeGreaterThan(payeeCount);
await expect(page).toMatchThemeScreenshots();
});
});

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