Compare commits
66 Commits
sync-serve
...
v25.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d47469be3 | ||
|
|
cbac6116d4 | ||
|
|
e83cfba357 | ||
|
|
0cac66b203 | ||
|
|
7983ee45e1 | ||
|
|
844cd3433a | ||
|
|
ae6bea2b15 | ||
|
|
37481535e7 | ||
|
|
45a4f0a40d | ||
|
|
9a3e33c0d7 | ||
|
|
25d072944e | ||
|
|
cf8a4b6e6a | ||
|
|
55b1ed170b | ||
|
|
b266ebf1ea | ||
|
|
6826ca0e4b | ||
|
|
80aee4ee71 | ||
|
|
ab4aa21343 | ||
|
|
9dd0284e31 | ||
|
|
dcc879294c | ||
|
|
002f74a8fa | ||
|
|
b6f80c26e6 | ||
|
|
ee71130d56 | ||
|
|
6ed18d8f8c | ||
|
|
1737674b9e | ||
|
|
e4617e8cd4 | ||
|
|
57d01467ca | ||
|
|
8019d9f61b | ||
|
|
ddbefc790e | ||
|
|
7eaf23eb7c | ||
|
|
8f284e7b60 | ||
|
|
be35328e42 | ||
|
|
23f1b7d3c0 | ||
|
|
8b1aa6fb93 | ||
|
|
155558ee62 | ||
|
|
94332016e8 | ||
|
|
0af2c6c2fb | ||
|
|
97482a082d | ||
|
|
31a9ba629b | ||
|
|
7c19a6333c | ||
|
|
2bce9d707c | ||
|
|
4e42cda29e | ||
|
|
0ce07d7692 | ||
|
|
0e3415c145 | ||
|
|
19675a7de6 | ||
|
|
5a1ceed7d9 | ||
|
|
86c1c30c97 | ||
|
|
96ac1292f9 | ||
|
|
328dfae8bf | ||
|
|
37247395e2 | ||
|
|
02b0e24d6e | ||
|
|
dfaa75f1cf | ||
|
|
adae3e4352 | ||
|
|
7a3794295f | ||
|
|
c1d97fcc75 | ||
|
|
3715f16888 | ||
|
|
edad7ce0e3 | ||
|
|
737341ffb6 | ||
|
|
6147495003 | ||
|
|
614bedcfbf | ||
|
|
248b1034d7 | ||
|
|
4f88afa266 | ||
|
|
5c23aad3c2 | ||
|
|
5b9bcc94f6 | ||
|
|
87d54251cd | ||
|
|
2e439aacba | ||
|
|
244140314c |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"setup-worktree": ["yarn"]
|
||||
}
|
||||
12
.github/actions/setup/action.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/check.yml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/docker-edge.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/e2e-test.yml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/electron-master.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/electron-pr.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/publish-npm-packages.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/size-compare.yml
vendored
@@ -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
|
||||
|
||||
119
.github/workflows/update-vrt.yml
vendored
@@ -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
@@ -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.`
|
||||
});
|
||||
105
.github/workflows/vrt-update-generate.yml
vendored
Normal 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
@@ -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/
|
||||
|
||||
@@ -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
948
.yarn/releases/yarn-4.9.1.cjs
vendored
@@ -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
@@ -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.
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
27
package.json
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,5 +5,11 @@ export default {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
poolOptions: {
|
||||
threads: {
|
||||
maxThreads: 2,
|
||||
minThreads: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
@@ -1,11 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated build artifacts
|
||||
manifest.json
|
||||
*.zip
|
||||
|
||||
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.zip
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -6,6 +6,6 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest"
|
||||
"test": "vitest --run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,10 @@ export default defineConfig({
|
||||
globals: true,
|
||||
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
|
||||
environment: 'node',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
packages/desktop-client/.gitignore
vendored
@@ -31,3 +31,6 @@ public/*.wasm
|
||||
|
||||
# translations
|
||||
locale/
|
||||
|
||||
# service worker build output
|
||||
dev-dist
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
@@ -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/,
|
||||
|
||||
64
packages/desktop-client/e2e/bank-sync.mobile.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
35
packages/desktop-client/e2e/bank-sync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 91 KiB |
13
packages/desktop-client/e2e/page-models/bank-sync-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
112
packages/desktop-client/e2e/payees.mobile.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 21 KiB |