Compare commits

..

56 Commits

Author SHA1 Message Date
lelemm
cef14e1a79 simplefin fixes 2025-10-08 15:54:02 -03:00
lelemm
1e5d5b9b78 simplefin 2025-10-08 15:28:17 -03:00
lelemm
33f6ae7f91 added server sync plugins and bank sync plugin support 2025-10-08 13:27:38 -03:00
lelemm
50fba76c47 feat: Implement proper plugin provider account fetching flow
- Remove placeholder console.log from plugin setup
- Implement real account fetching using 'bank-sync-accounts' handler
- Follow same pattern as PluggyAI: check error_code, fetch accounts, show select-linked-accounts modal
- Add proper error handling with notifications
- Auto-retry connection after plugin setup completion
2025-10-08 11:44:56 -03:00
lelemm
744ae1625d fix: Correct response format parsing in useBankSyncProviders hook
- Update hook to expect { providers: BankSyncProvider[] } format
- Remove incorrect status/data wrapper expectation
- Match actual loot-core handler response format
2025-10-08 11:33:58 -03:00
lelemm
9dda58b61d feat: Add bank-sync status endpoint to sync-server
- Add /plugins-api/bank-sync/:providerSlug/status route
- Checks if plugin exists and has bankSync enabled
- Calls plugin's status endpoint via middleware if defined
- Returns configured status for plugin availability
- Proper error handling and fallback responses
2025-10-08 11:29:17 -03:00
lelemm
734bb86126 feat: Implement bank-sync-status handler with proper plugin API calls
- Add getPluginStatus function that calls /plugins-api/bank-sync/{slug}/status
- Remove credential storage logic from frontend - handled by plugin system
- Proper error handling and TypeScript fixes
- Plugin system handles authentication and credentials internally
2025-10-08 11:26:53 -03:00
lelemm
efb0d80aa4 feat: Implement real plugin provider functionality
- Replace placeholder implementations with actual plugin system integration
- getPluginProviders: Calls /plugins-api/bank-sync/list endpoint to fetch available plugins
- getPluginAccounts: Calls plugin-specific /plugins-api/bank-sync/{slug}/accounts endpoints
- Proper error handling with authentication and server validation
- Full integration with existing plugin manager and middleware system
2025-10-08 11:16:57 -03:00
lelemm
605206d2f7 feat: Integrate plugin-based bank sync providers
- Add plugin bank sync providers to CreateAccountModal alongside existing providers
- Extend SelectLinkedAccountsModal to handle plugin accounts with unified interface
- Implement backend API handlers: bank-sync-providers-list, bank-sync-accounts, bank-sync-accounts-link
- Add linkAccountPlugin action for Redux state management
- Maintain full backward compatibility with existing GoCardless, SimpleFIN, Pluggy.ai providers
- Type-safe integration with proper TypeScript definitions
- Placeholder implementations ready for real plugin functionality

This enables the plugin architecture for bank sync while preserving existing functionality, ready for feature flag control.
2025-10-08 11:12:01 -03:00
Matiss Janis Aboltins
f7b40fca64 Add swipe to delete to mobile rules (#5871) 2025-10-07 20:33:46 +02:00
Stephen Brown II
dc811552be feat(currency): Currency-influenced initial number formats (#5797) 2025-10-07 19:05:16 +01:00
lelemm
295839ebbb 🐛 Fix for worker in dev mode (#5878)
* Fix for worker in dev mode

* Add release notes for PR #5878

* trigger actions

---------

Co-authored-by: Leandro Menezes <leandro.menezes@fusionflowsoftware.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-07 14:45:13 -03:00
Michael Süssemilch
99ca34458e feat(currency): add currency display to rules (#5639)
* feat(currency): add to rules

* doc: release notes

* feat: remove keydown from Input

* doc: release notes

* fix: make onEnter optional

* fix: ai remark

* refactor: remove onKeyDown from Input.tsx

* fix: handle Amount (inflow) and Amount (outflow) properly

* [autofix.ci] apply automated fixes

* fix: update AmountInput to sign and on outflow set +

* refactor: onSubmit handling of input value

* coderabbit suggestions

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-07 10:44:21 -07:00
lelemm
90ac8d8520 📚 More Translations (#5812)
* Translations

* linter

* Add release notes for PR #5812

* actions trigger

* md category change

* [autofix.ci] apply automated fixes

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

* typecheck fix

* linter

* more linter

* omg

* Fixes

* [autofix.ci] apply automated fixes

* Code review change

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Leandro Menezes <leandro.menezes@fusionflowsoftware.com>
2025-10-07 14:36:10 -03:00
Matt Fiddaman
52aeec2d59 ♻️ bump react dependencies (#5865) 2025-10-07 17:50:41 +01:00
lelemm
0c280d60f6 Frontend plugins Support [3/10]: System-Wide Feature Flag System + Frontend plugins feature flag (#5785)
Support for global feature flag and minimum custom theme prefs for plugins
2025-10-07 13:13:31 -03:00
Roque Alejandro Sosa
148ca92584 Added ARS currency (#5869)
* Added ARS currency

* Added correct release number

---------

Co-authored-by: Ras <git.boasting733@passinbox.com>
2025-10-07 08:28:38 -07:00
Ilyos Khurozov
90e848ebe8 Added support for Uzbek Soum (UZS) (#5876) 2025-10-07 08:16:43 -07:00
lelemm
b034d5039f Frontend plugins Support [2/10]: Plugin service worker (#5784)
* Plugin service worker
2025-10-07 12:14:32 -03:00
Matiss Janis Aboltins
5ac29473f2 Mobile payees - swipe to delete (#5824) 2025-10-06 19:23:52 +01:00
Matt Fiddaman
3b0db2bed7 ♻️ bump various build dependencies (#5864)
* vite 7.1.9

* typescript 5.9.3

* @types/node 22.18.8

* linting

* emscripten types

* note
2025-10-06 17:32:42 +01:00
Michael Clark
7a886810bc :electron: Hide the Electron menu (#5847)
* add retries to electron server import

* release notes

* get rid of this menu. If its an app functionality it should be available within the app

* hide the menu - update the ui

* fix function call

* Update VRT

* release notes

* spelling mistake

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-06 17:13:47 +01:00
Haritha Hasathcharu
8bf0997275 Add LKR and CRC currencies (#5848) 2025-10-06 08:41:59 -07:00
Matt Fiddaman
2f965266ab run schedule rules regardless of posted date (#5870)
* run schedule rules regardless of date

* note
2025-10-06 16:31:24 +01:00
Matt Fiddaman
499f24f7fd ♻️ bump non-react deps in desktop-client (#5858)
* patch/minor deps

* @vitejs/plugin-basic-ssl 2.1.0

* remove chokidar

* cross-env 10.1.0

* downshift 9.0.10

* remove focus-visible

* jsdom 27.0.0

* rollup-plugin-visualizer 6.0.4

* note
2025-10-06 16:28:04 +01:00
Matiss Janis Aboltins
4c5be62f56 Mobile payees - add loading indicator to rules count label (#5842) 2025-10-05 19:57:27 +01:00
Matiss Janis Aboltins
1446c7d93f Mobile rules - refactor to use react-aria GridList component (#5804) 2025-10-05 19:57:06 +01:00
Julian Dominguez-Schatz
ad9980307e Fix React compiler behaviour in dev mode (#5853)
* Fix React compiler behaviour in dev mode

* Add release notes

* Add comment
2025-10-05 07:14:03 -07:00
dependabot[bot]
d4ad31fb0c Bump tar-fs from 2.1.3 to 2.1.4 (#5796)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.3 to 2.1.4.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.3...v2.1.4)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-05 14:51:07 +01:00
Matt Fiddaman
05355788e4 ♻️ bump sync-server dependencies (#5819)
* uuid 11.1.0 -> 13.0.0

* better-sqlite3 12.2.0 -> 12.4.1

* debug 4.4.1 -> 4.4.3

* express-rate-limit 8.0.1 -> 8.1.0

* pluggy-sdk 0.74.0 -> 0.77.0

* babel/core 7.28.0 -> 7.28.4

* note
2025-10-05 14:36:57 +01:00
Stephen Brown II
805e2b1807 Align amount conversion utilities between api and loot-core (#5747)
* Align amount conversion utilities between api and loot-core

Updates api amount conversion utilities to align with loot-core, improving consistency and maintainability across the project.

Uses decimal places as parameters in conversion functions.

* Moves amount conversion utils to core

Moves amount conversion utilities to the core library.

This change consolidates these utilities for better code reuse
and maintainability across different parts of the application.
It removes the duplicate definition from the API package and
imports it from the core library where it is shared.
2025-10-05 14:23:34 +01:00
Çağdaş Şenel
e54dc0c1ca fix losing transaction amount decimals on update (#5807) 2025-10-05 14:23:15 +01:00
Çağdaş Şenel
e1c2f0a181 feat: show full decimals while editing (#5808)
* show full decimals while editing

* add changes

* handle null
2025-10-05 14:23:04 +01:00
Matt Fiddaman
cc2e329e8e show empty data points on line graph reports (#5815)
* draw zero points

* note

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-05 14:22:51 +01:00
Matt Fiddaman
71f849d1e1 ♻️ bump eslint-plugin-actual dependencies (#5818)
* eslint 9.27.0 -> 9.36.0

* eslint-plugin-eslint-plugin 6.4.0 -> 7.0.0

* eslint-vitest-rule-tester 2.2.0 -> 2.2.2

* note

* misc eslint deps
2025-10-05 14:22:42 +01:00
Matt Fiddaman
0ea8bc1fb4 expand eslint untranslated string rule (#5827)
* expand translation rule and abstract import fix implementation

* fixes

* note

* coderabbit
2025-10-05 14:22:14 +01:00
Matt Fiddaman
f0c7953c0b ♻️ refactor rules code (#5837)
* extract handlebars helpers

* extract condition types

* extract condition class

* extract action class

* extract rule class

* extract rule indexer

* extract rule utils

* update main index

* note

* enable strict where able

* generalise assert

* coderabbit

* move condition-types into condition, move helper functions into rule-utils
2025-10-05 14:22:02 +01:00
Matt Fiddaman
4cf5f9b183 add average per year calculation to the summary report (#5838)
* add average per year to summary report

* note

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-05 14:21:52 +01:00
Michael Clark
80fd997540 Reports - Add an option to trim the start & end intervals (#5641)
* initial test to trim the intervals

* bit more

* got the logic

* fix table data

* add migration for trim intervals

* release notes

* nice work rabbit

* small cleanup

* not sure how major that is but yeah why not
2025-10-05 10:48:35 +01:00
Michael Clark
da93ddf63b 🛠️ Add retries to electron loot-core import (#5843)
* add retries to electron server import

* release notes
2025-10-05 10:48:05 +01:00
github-actions[bot]
7846d2e787 🔖 (25.10.0) (#5834)
* 🔖 (25.10.0)

* Remove used release notes

* Remove used release notes

---------

Co-authored-by: matt-fidd <81489167+matt-fidd@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-02 11:55:47 +01:00
youngcw
ca6d80461a 🐛 fix limit checker (#5835) 2025-10-01 09:04:34 -07:00
Matt Fiddaman
fa14cbb697 fix decimal input in amount input boxes (#5831)
* preserve decimal seperators while typing in input boxes

* note
2025-10-01 16:27:51 +01:00
Matt Fiddaman
1210a74b4a fix error handling for simplefin batch sync (#5822)
* fix error handling for batch simplefin sync

* note
2025-09-30 23:51:17 +01:00
Matt Fiddaman
534c1e6680 fix crash when switching reports (#5823)
* fix report autocomplete

* note
2025-09-30 21:24:46 +01:00
Matt Fiddaman
14d436712a move balance history graph to live queries (#5821)
* move balance history graph to live queries

* note

* [autofix.ci] apply automated fixes

* coderabbit suggestion

* fix null starting balance

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-30 20:27:22 +01:00
Matt Fiddaman
e9f3925124 prevent the account balance graph from showing on small screen sizes (#5816)
* prevent account balance graph from showing on small screen sizes

* note

* lint
2025-09-30 00:46:26 +01:00
Matt Fiddaman
f28229be99 fix payee autocomplete hovering randomly (#5817)
* use correct index for payee autocomplete hover states

* note

* coderabbit
2025-09-30 00:46:07 +01:00
Matt Fiddaman
1fc922c672 skip running the schedule service if the database is not loaded (#5810) 2025-09-29 14:29:17 +01:00
Matiss Janis Aboltins
c712217a7c Update PayeesList component to use flex styling for improved layout consistency (#5803) 2025-09-28 06:31:30 +01:00
Matiss Janis Aboltins
3559b2df3a Mobile Payees - move to react-aria GridList to improve performance (#5802) 2025-09-27 21:54:16 +01:00
Matt Fiddaman
6365a8f4bb replace deprecated function in count points script (#5791)
* fix deprecated function call in count points script

* note
2025-09-25 21:03:44 +01:00
Matt Fiddaman
14426b64fd fix live report time ranges (#5790)
* fix live range

* note

* use latest of currentMonth/latestTransaction

* [autofix.ci] apply automated fixes

* standardise card code

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-25 19:16:40 +01:00
Matiss Janis Aboltins
65790d4b9c Fix token expiration parsing (#5782) 2025-09-25 18:07:26 +01:00
Michael Clark
9af4ba4d07 🔽 Downgrade Ubuntu image for more compatibility with older distros (#5788)
* downgrade ubuntu image for more compatibility with older distros

* release ntoes
2025-09-24 19:37:31 +01:00
Matiss Janis Aboltins
28caf8eaf9 Add data-1p-ignore to transaction amounts (#5783) 2025-09-24 17:15:16 +01:00
354 changed files with 86823 additions and 9709 deletions

View File

@@ -1,5 +1,5 @@
---
description:
description:
globs: *.ts,*.tsx
alwaysApply: false
---
@@ -21,7 +21,7 @@ Naming Conventions
TypeScript Usage
- Use TypeScript for all code; prefer interfaces over types.
- 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`.

View File

@@ -2,7 +2,7 @@ import { Octokit } from '@octokit/rest';
import { minimatch } from 'minimatch';
import pLimit from 'p-limit';
const limit = pLimit(30);
const limit = pLimit(50);
/** Repository-specific configuration for points calculation */
const REPOSITORY_CONFIG = new Map([
@@ -129,13 +129,13 @@ async function countContributorPoints(repo) {
// Get all PRs using search
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
const recentPRs = await octokit.paginate(
octokit.search.issuesAndPullRequests,
'GET /search/issues',
{
q: searchQuery,
per_page: 100,
advanced_search: true,
},
response => response.data,
response => response.data.filter(pr => pr.number),
);
// Get reviews and PR details for each PR

View File

@@ -50,22 +50,6 @@ jobs:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
plugins-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Plugins Core
run: yarn workspace @actual-app/plugins-core build
- name: Create package tgz
run: cd packages/plugins-core && yarn pack && mv package.tgz actual-plugins-core.tgz
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: actual-plugins-core
path: packages/plugins-core/actual-plugins-core.tgz
web:
runs-on: ubuntu-latest
steps:

View File

@@ -32,7 +32,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.55.1-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -53,7 +53,7 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.55.1-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -74,7 +74,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.55.1-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment

View File

@@ -24,7 +24,7 @@ jobs:
strategy:
matrix:
os:
- ubuntu-latest
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}

View File

@@ -19,7 +19,7 @@ jobs:
strategy:
matrix:
os:
- ubuntu-latest
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}

View File

@@ -19,7 +19,7 @@ jobs:
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
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

2
.gitignore vendored
View File

@@ -26,6 +26,8 @@ packages/desktop-electron/build
packages/desktop-electron/.electron-symbols
packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
bundle.desktop.js
bundle.desktop.js.map
bundle.mobile.js

605
PLUGIN_ARCHITECTURE.md Normal file
View File

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

View File

@@ -16,6 +16,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser

View File

@@ -41,6 +41,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build

View File

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

View File

@@ -74,32 +74,30 @@ const confusingBrowserGlobals = [
export default pluginTypescript.config(
{
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/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'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-core/build/',
'packages/plugins-core/node_modules/',
'.yarn/*',
'.github/*',
],
@@ -765,6 +763,18 @@ 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',

View File

@@ -23,17 +23,20 @@
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
@@ -44,7 +47,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "prettier --check . && eslint . --max-warnings 0",
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
@@ -55,32 +58,32 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.17.0",
"@types/node": "^22.18.8",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.42.0",
"cross-env": "^7.0.3",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.3.5",
"eslint-plugin-import": "^2.31.0",
"@typescript-eslint/parser": "^8.45.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.0.0-rc.2",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"lint-staged": "^16.2.3",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"prettier": "^3.5.3",
"prettier": "^3.6.2",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "25.9.0",
"version": "25.10.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -24,14 +24,14 @@
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.2.0",
"better-sqlite3": "^12.4.1",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^11.1.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

View File

@@ -1,7 +0,0 @@
export function amountToInteger(n) {
return Math.round(n * 100);
}
export function integerToAmount(n) {
return parseFloat((n / 100).toFixed(2));
}

1
packages/api/utils.ts Normal file
View File

@@ -0,0 +1 @@
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,14 +8,14 @@
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.8.0",
"react-aria-components": "^1.13.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.1.12",
"react": "19.1.1",
"react-dom": "19.1.1",
"@types/react": "^19.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"vitest": "^3.2.4"
},
"exports": {
@@ -49,8 +49,7 @@
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",
"./view": "./src/View.tsx",
"./color-picker": "./src/ColorPicker.tsx",
"./props/*": "./src/props/*.ts"
"./color-picker": "./src/ColorPicker.tsx"
},
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",

View File

@@ -1,11 +0,0 @@
import { CSSProperties } from '../styles';
export type BasicModalProps = {
isLoading?: boolean;
noAnimation?: boolean;
style?: CSSProperties;
onClose?: () => void;
containerProps?: {
style?: CSSProperties;
};
};

View File

@@ -154,4 +154,10 @@ export const styles: Record<string, any> = {
borderRadius: 4,
padding: '3px 5px',
},
mobileListItem: {
borderBottom: `1px solid ${theme.tableBorder}`,
backgroundColor: theme.tableBackground,
padding: 16,
cursor: 'pointer',
},
};

View File

@@ -17,13 +17,13 @@
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^11.1.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-4",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

View File

@@ -14,6 +14,9 @@ build-electron
build-stats
stats.json
# generated service worker
service-worker/
# misc
.DS_Store
.env

View File

@@ -65,10 +65,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-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.52.0-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
# Use the ip and port noted earlier

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -40,17 +40,14 @@ export class MobileRulesPage {
* Get the nth rule item (0-based index)
*/
getNthRule(index: number) {
return this.page
.getByRole('button')
.filter({ hasText: /IF|THEN/ })
.nth(index);
return this.getAllRules().nth(index);
}
/**
* Get all visible rule items
*/
getAllRules() {
return this.page.getByRole('button').filter({ hasText: /IF|THEN/ });
return this.page.getByRole('grid', { name: 'Rules' }).getByRole('row');
}
/**
@@ -112,7 +109,7 @@ export class MobileRulesPage {
*/
async getRuleStage(index: number) {
const rule = this.getNthRule(index);
const stageBadge = rule.locator('span').first();
const stageBadge = rule.getByTestId('rule-stage-badge');
return await stageBadge.textContent();
}
}

View File

@@ -122,7 +122,15 @@ export class RulesPage {
if (op && !fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page.getByRole('button', { name: op, exact: true }).click();
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.click({ force: true });
}
if (field) {
@@ -133,12 +141,26 @@ export class RulesPage {
.click();
await this.page
.getByRole('button', { name: field, exact: true })
.click();
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: field, exact: true })
.first()
.click({ force: true });
}
if (op && fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page.getByRole('button', { name: op, exact: true }).click();
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.click({ force: true });
}
if (value) {

View File

@@ -28,7 +28,10 @@ export class SchedulesPage {
await this._fillScheduleFields(data);
await this.page.getByRole('button', { name: 'Add' }).click();
await this.page
.getByTestId('schedule-edit-modal')
.getByRole('button', { name: 'Add' })
.click();
}
/**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -155,6 +155,8 @@ test.describe('Transactions', () => {
await expect(transaction.category.locator('input')).toHaveValue('Transfer');
await expect(page).toMatchThemeScreenshots();
const balanceBeforeTransaction =
await accountPage.accountBalance.textContent();
await accountPage.addEnteredTransaction();
transaction = accountPage.getNthTransaction(0);
@@ -163,6 +165,14 @@ test.describe('Transactions', () => {
await expect(transaction.category).toHaveText('Transfer');
await expect(transaction.debit).toHaveText('12.34');
await expect(transaction.credit).toHaveText('');
// Wait for balance to update after adding transaction
await expect(async () => {
const balanceAfterTransaction =
await accountPage.accountBalance.textContent();
expect(balanceAfterTransaction).not.toBe(balanceBeforeTransaction);
}).toPass();
await expect(page).toMatchThemeScreenshots();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -7,7 +7,6 @@
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Actual</title>
<link rel="canonical" href="/" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
@@ -108,10 +107,6 @@
min-height: 0;
min-width: 0;
}
.js-focus-visible :focus:not(.focus-visible) {
outline: 0;
}
</style>
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "25.9.0",
"version": "25.10.0",
"license": "MIT",
"files": [
"build"
@@ -8,38 +8,36 @@
"devDependencies": {
"@actual-app/components": "workspace:*",
"@emotion/css": "^11.13.5",
"@fontsource/redacted-script": "^5.2.5",
"@fontsource/redacted-script": "^5.2.8",
"@juggle/resize-observer": "^3.4.0",
"@playwright/test": "1.52.0",
"@playwright/test": "1.55.1",
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.11.24",
"@swc/core": "^1.13.5",
"@swc/helpers": "^0.5.17",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "^4",
"@types/promise-retry": "^1.1.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/react-grid-layout": "^1",
"@types/react-modal": "^3.16.3",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-react": "^5.0.2",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-react": "^5.0.4",
"auto-text-size": "^0.2.3",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"chokidar": "^3.6.0",
"cmdk": "^1.1.1",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"date-fns": "^4.1.0",
"downshift": "7.6.2",
"focus-visible": "^4.1.5",
"i18next": "^25.2.1",
"downshift": "9.0.10",
"i18next": "^25.5.3",
"i18next-parser": "^9.3.0",
"i18next-resources-to-backend": "^1.2.1",
"inter-ui": "^3.19.3",
"jsdom": "^26.1.0",
"jsdom": "^27.0.0",
"lodash": "^4.17.21",
"loot-core": "workspace:*",
"mdast-util-newline-to-break": "^2.0.0",
@@ -48,35 +46,34 @@
"promise-retry": "^2.0.1",
"prop-types": "^15.8.1",
"re-resizable": "^6.11.2",
"react": "19.1.1",
"react-aria": "^3.39.0",
"react-aria-components": "^1.8.0",
"react": "19.2.0",
"react-aria": "^3.44.0",
"react-aria-components": "^1.13.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.1",
"react-error-boundary": "^5.0.0",
"react-grid-layout": "^1.5.1",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.5.3",
"react-dom": "19.2.0",
"react-error-boundary": "^6.0.0",
"react-grid-layout": "^1.5.2",
"react-hotkeys-hook": "^5.1.0",
"react-i18next": "^16.0.0",
"react-markdown": "^10.1.0",
"react-modal": "3.16.3",
"react-redux": "^9.2.0",
"react-router": "7.6.2",
"react-router": "7.9.3",
"react-simple-pull-to-refresh": "^1.3.3",
"react-spring": "^10.0.0",
"react-stately": "^3.37.0",
"react-swipeable": "^7.0.2",
"react-virtualized-auto-sizer": "^1.0.26",
"recharts": "^2.15.3",
"rehype-external-links": "^3.0.0",
"remark-gfm": "^4.0.1",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.89.0",
"rollup-plugin-visualizer": "^6.0.4",
"sass": "^1.93.2",
"usehooks-ts": "^3.1.1",
"uuid": "^11.1.0",
"vite": "^6.3.6",
"vite-plugin-pwa": "^1.0.0",
"vite-tsconfig-paths": "^4.3.2",
"uuid": "^13.0.0",
"vite": "^7.1.9",
"vite-plugin-pwa": "^1.0.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"xml2js": "^0.6.2"
},

View File

@@ -313,6 +313,41 @@ export const linkAccountPluggyAi = createAppAsyncThunk(
},
);
type LinkAccountPluginPayload = {
accountId: string;
externalAccount: {
account_id: string;
name: string;
institution: string;
balance: number;
[key: string]: any;
};
syncSource: 'plugin';
providerSlug: string;
upgradingId?: AccountEntity['id'] | undefined;
};
export const linkAccountPlugin = createAppAsyncThunk(
`${sliceName}/linkAccountPlugin`,
async (
{
accountId: _accountId,
externalAccount,
providerSlug,
upgradingId,
}: LinkAccountPluginPayload,
{ dispatch },
) => {
await send('bank-sync-accounts-link', {
providerSlug,
externalAccount,
upgradingId,
});
dispatch(markPayeesDirty());
dispatch(markAccountsDirty());
},
);
function handleSyncResponse(
accountId: AccountEntity['id'],
res: SyncResponseWithErrors,

View File

@@ -178,7 +178,6 @@ global.Actual = {
// Wait for the app to reload
await new Promise(() => {});
},
updateAppMenu: () => {},
ipcConnect: () => {},
getServerSocket: async () => {
@@ -191,35 +190,3 @@ global.Actual = {
moveBudgetDirectory: () => {},
};
function inputFocused(e) {
return (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
);
}
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey) {
// Cmd/Ctrl+o
if (e.key === 'o') {
e.preventDefault();
window.__actionsForMenu.closeBudget();
}
// Cmd/Ctrl+z
else if (e.key.toLowerCase() === 'z') {
if (inputFocused(e)) {
return;
}
e.preventDefault();
if (e.shiftKey) {
// Redo
window.__actionsForMenu.redo();
} else {
// Undo
window.__actionsForMenu.undo();
}
}
}
});

View File

@@ -212,10 +212,28 @@ export const moveCategoryGroup = createAppAsyncThunk(
},
);
function translateCategories(
categories: CategoryEntity[] | undefined,
): CategoryEntity[] | undefined {
return categories?.map(cat => ({
...cat,
name:
cat.name?.toLowerCase() === 'starting balances'
? t('Starting Balances')
: cat.name,
}));
}
export const getCategories = createAppAsyncThunk(
`${sliceName}/getCategories`,
async () => {
const categories: CategoryViews = await send('get-categories');
categories.list = translateCategories(categories.list) as CategoryEntity[];
categories.grouped.forEach(group => {
group.categories = translateCategories(
group.categories,
) as CategoryEntity[];
});
return categories;
},
{
@@ -233,6 +251,12 @@ export const reloadCategories = createAppAsyncThunk(
`${sliceName}/reloadCategories`,
async () => {
const categories: CategoryViews = await send('get-categories');
categories.list = translateCategories(categories.list) as CategoryEntity[];
categories.grouped.forEach(group => {
group.categories = translateCategories(
group.categories,
) as CategoryEntity[];
});
return categories;
},
);
@@ -556,6 +580,7 @@ export const getCategoriesById = memoizeOne(
res[cat.id] = cat;
});
});
return res;
},
);
@@ -584,6 +609,12 @@ function _loadCategories(
categories: BudgetState['categories'],
) {
state.categories = categories;
categories.list = translateCategories(categories.list) as CategoryEntity[];
categories.grouped.forEach(group => {
group.categories = translateCategories(
group.categories,
) as CategoryEntity[];
});
state.isCategoriesLoading = false;
state.isCategoriesLoaded = true;
state.isCategoriesDirty = false;

View File

@@ -135,10 +135,6 @@ function AppInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, showErrorBoundary]);
useEffect(() => {
global.Actual.updateAppMenu(budgetId);
}, [budgetId]);
useEffect(() => {
if (userData?.tokenExpired) {
dispatch(

View File

@@ -97,7 +97,7 @@ export const HelpMenu = () => {
}
};
useHotkeys('shift+?', () => setMenuOpen(true));
useHotkeys('?', () => setMenuOpen(true), { useKey: true });
return (
<SpaceBetween>

View File

@@ -8,6 +8,7 @@ import * as monthUtils from 'loot-core/shared/months';
import { EditSyncAccount } from './banksync/EditSyncAccount';
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
import { AccountMenuModal } from './modals/AccountMenuModal';
import { BankSyncInitialiseModal } from './modals/BankSyncInitialiseModal';
import { BudgetAutomationsModal } from './modals/BudgetAutomationsModal';
import { BudgetFileSelectionModal } from './modals/BudgetFileSelectionModal';
import { BudgetPageMenuModal } from './modals/BudgetPageMenuModal';
@@ -183,6 +184,9 @@ export function Modals() {
/>
);
case 'bank-sync-init':
return <BankSyncInitialiseModal key={key} {...modal.options} />;
case 'create-encryption-key':
return <CreateEncryptionKeyModal key={key} {...modal.options} />;

View File

@@ -35,12 +35,14 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
development: SvgMoonStars,
} as const;
type ThemeIconKey = keyof typeof themeIcons;
function onMenuSelect(newTheme: Theme) {
setMenuOpen(false);
switchTheme(newTheme);
}
const Icon = themeIcons[theme] || SvgSun;
const Icon = themeIcons[theme as ThemeIconKey] || SvgSun;
if (isNarrowWidth) {
return null;

View File

@@ -20,10 +20,7 @@ import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { listen } from 'loot-core/platform/client/fetch';
import {
isDevelopmentEnvironment,
isElectron,
} from 'loot-core/shared/environment';
import { isDevelopmentEnvironment } from 'loot-core/shared/environment';
import * as Platform from 'loot-core/shared/platform';
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
@@ -352,7 +349,7 @@ export function Titlebar({ style }: TitlebarProps) {
<PrivacyButton />
{serverURL ? <SyncButton /> : null}
<LoggedInUser />
{!isElectron() && <HelpMenu />}
<HelpMenu />
</SpaceBetween>
</View>
);

View File

@@ -20,38 +20,67 @@ import { useDispatch } from '@desktop-client/redux';
function useErrorMessage() {
const { t } = useTranslation();
function getErrorMessage(type: string, code: string) {
switch (type.toUpperCase()) {
case 'ITEM_ERROR':
switch (code.toUpperCase()) {
case 'NO_ACCOUNTS':
return t(
'No open accounts could be found. Did you close the account? If so, unlink the account.',
);
case 'ITEM_LOGIN_REQUIRED':
return t(
'Your password or something else has changed with your bank and you need to login again.',
);
default:
}
break;
// Handle standardized bank sync error codes
switch (code.toUpperCase()) {
case 'INVALID_CREDENTIALS':
return t(
'Your credentials are invalid. Please reconfigure your bank connection.',
);
case 'INVALID_INPUT':
switch (code.toUpperCase()) {
case 'INVALID_ACCESS_TOKEN':
return t('Item is no longer authorized. You need to login again.');
default:
}
break;
case 'INVALID_ACCESS_TOKEN':
return t(
'Your access token is no longer valid. Please reconfigure your bank connection.',
);
case 'UNAUTHORIZED':
return t(
'Access forbidden. Please check your permissions and reconfigure if needed.',
);
case 'ACCOUNT_NOT_FOUND':
return t(
'Account not found. Please verify your account configuration.',
);
case 'TRANSACTION_NOT_FOUND':
return t('Transaction data not found. Please try again later.');
case 'SERVER_ERROR':
return t(
'The bank sync provider is experiencing issues. Please try again later.',
);
case 'NETWORK_ERROR':
return t(
'Network error communicating with your bank. Please check your connection and try again.',
);
case 'RATE_LIMIT':
case 'RATE_LIMIT_EXCEEDED':
return t('Rate limit exceeded for this item. Please try again later.');
return t('Rate limit exceeded. Please try again later.');
case 'INVALID_REQUEST':
return t(
'Invalid request. Please check your account configuration and try again.',
);
case 'ACCOUNT_LOCKED':
return t(
'Your account is locked. Please contact your bank for assistance.',
);
case 'TIMED_OUT':
return t('The request timed out. Please try again later.');
case 'INVALID_ACCESS_TOKEN':
// Legacy error codes for backwards compatibility
case 'NO_ACCOUNTS':
return t(
'Your SimpleFIN Access Token is no longer valid. Please reset and generate a new token.',
'No open accounts could be found. Did you close the account? If so, unlink the account.',
);
case 'ITEM_LOGIN_REQUIRED':
return t(
'Your password or something else has changed with your bank and you need to login again.',
);
case 'ACCOUNT_NEEDS_ATTENTION':
@@ -71,9 +100,22 @@ function useErrorMessage() {
default:
}
// Legacy type-based error handling
switch (type.toUpperCase()) {
case 'ITEM_ERROR':
return t(
'There was an error with your bank connection. Please try logging in again.',
);
case 'INVALID_INPUT':
return t('Invalid input. Please check your configuration.');
default:
}
return (
<Trans>
An internal error occurred. Try to log in again, or get{' '}
An internal error occurred. Try to reconfigure your connection, or get{' '}
<Link variant="external" to="https://actualbudget.org/contact/">
in touch
</Link>{' '}

View File

@@ -22,7 +22,7 @@ import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { useLocale } from '@desktop-client/hooks/useLocale';
import * as query from '@desktop-client/queries';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { liveQuery } from '@desktop-client/queries/liveQuery';
const LABEL_WIDTH = 70;
@@ -32,11 +32,6 @@ type BalanceHistoryGraphProps = {
ref?: Ref<HTMLDivElement>;
};
type Balance = {
date: string;
balance: number;
};
export function BalanceHistoryGraph({
accountId,
style,
@@ -51,6 +46,11 @@ export function BalanceHistoryGraph({
date: string;
balance: number;
} | null>(null);
const [startingBalance, setStartingBalance] = useState<number | null>(null);
const [monthlyTotals, setMonthlyTotals] = useState<Array<{
date: string;
balance: number;
}> | null>(null);
const percentageChange = useMemo(() => {
if (balanceData.length < 2) return 0;
@@ -65,7 +65,70 @@ export function BalanceHistoryGraph({
);
useEffect(() => {
async function fetchBalanceHistory() {
// Reset state when accountId changes
setStartingBalance(null);
setMonthlyTotals(null);
setLoading(true);
const endDate = new Date();
const startDate = subMonths(endDate, 12);
const startingBalanceQuery = query
.transactions(accountId)
.filter({
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
})
.calculate({ $sum: '$amount' });
const monthlyTotalsQuery = query
.transactions(accountId)
.filter({
$and: [
{ date: { $gte: monthUtils.firstDayOfMonth(startDate) } },
{ date: { $lte: monthUtils.lastDayOfMonth(endDate) } },
],
})
.groupBy({ $month: '$date' })
.select([{ date: { $month: '$date' } }, { amount: { $sum: '$amount' } }]);
const startingBalanceLive: ReturnType<typeof liveQuery<number>> = liveQuery(
startingBalanceQuery,
{
onData: (data: number[]) => {
setStartingBalance(data[0] || 0);
},
onError: error => {
console.error('Error fetching starting balance:', error);
setLoading(false);
},
},
);
const monthlyTotalsLive: ReturnType<
typeof liveQuery<{ date: string; amount: number }>
> = liveQuery(monthlyTotalsQuery, {
onData: (data: Array<{ date: string; amount: number }>) => {
setMonthlyTotals(
data.map(d => ({
date: d.date,
balance: d.amount,
})),
);
},
onError: error => {
console.error('Error fetching monthly totals:', error);
setLoading(false);
},
});
return () => {
startingBalanceLive?.unsubscribe();
monthlyTotalsLive?.unsubscribe();
};
}, [accountId, locale]);
// Process data when both startingBalance and monthlyTotals are available
useEffect(() => {
if (startingBalance !== null && monthlyTotals !== null) {
const endDate = new Date();
const startDate = subMonths(endDate, 12);
const months = eachMonthOfInterval({
@@ -73,99 +136,70 @@ export function BalanceHistoryGraph({
end: endDate,
}).map(m => format(m, 'yyyy-MM'));
const [starting, totals]: [number, Balance[]] = await Promise.all([
aqlQuery(
query
.transactions(accountId)
.filter({
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
})
.calculate({ $sum: '$amount' }),
).then(({ data }) => data),
aqlQuery(
query
.transactions(accountId)
.filter({
$and: [
{ date: { $gte: monthUtils.firstDayOfMonth(startDate) } },
{ date: { $lte: monthUtils.lastDayOfMonth(endDate) } },
],
})
.groupBy({ $month: '$date' })
.select([
{ date: { $month: '$date' } },
{ amount: { $sum: '$amount' } },
]),
).then(({ data }) =>
data.map((d: { date: string; amount: number }) => {
return {
date: d.date,
balance: d.amount,
};
}),
),
]);
// calculate balances from sum of transactions
let currentBalance = starting;
totals.reverse().forEach(month => {
currentBalance = currentBalance + month.balance;
month.balance = currentBalance;
});
// if the account doesn't have recent transactions
// then the empty months will be missing from our data
// so add in entries for those here
if (totals.length === 0) {
//handle case of no transactions in the last year
months.forEach(expectedMonth =>
totals.push({
date: expectedMonth,
balance: starting,
}),
);
} else if (totals.length < months.length) {
// iterate through each array together and add in missing data
let totalsIndex = 0;
let mostRecent = starting;
months.forEach(expectedMonth => {
if (totalsIndex > totals.length - 1) {
// fill in the data at the end of the window
totals.push({
date: expectedMonth,
balance: mostRecent,
});
} else if (totals[totalsIndex].date === expectedMonth) {
// a matched month
mostRecent = totals[totalsIndex].balance;
totalsIndex += 1;
} else {
// a missing month in the middle
totals.push({
date: expectedMonth,
balance: mostRecent,
});
}
function processData(
startingBalanceValue: number,
monthlyTotalsValue: Array<{ date: string; balance: number }>,
) {
let currentBalance = startingBalanceValue;
const totals = [...monthlyTotalsValue];
totals.reverse().forEach(month => {
currentBalance = currentBalance + month.balance;
month.balance = currentBalance;
});
// if the account doesn't have recent transactions
// then the empty months will be missing from our data
// so add in entries for those here
if (totals.length === 0) {
//handle case of no transactions in the last year
months.forEach(expectedMonth =>
totals.push({
date: expectedMonth,
balance: startingBalanceValue,
}),
);
} else if (totals.length < months.length) {
// iterate through each array together and add in missing data
let totalsIndex = 0;
let mostRecent = startingBalanceValue;
months.forEach(expectedMonth => {
if (totalsIndex > totals.length - 1) {
// fill in the data at the end of the window
totals.push({
date: expectedMonth,
balance: mostRecent,
});
} else if (totals[totalsIndex].date === expectedMonth) {
// a matched month
mostRecent = totals[totalsIndex].balance;
totalsIndex += 1;
} else {
// a missing month in the middle
totals.push({
date: expectedMonth,
balance: mostRecent,
});
}
});
}
const balances = totals
.sort((a, b) => monthUtils.differenceInCalendarMonths(a.date, b.date))
.map(t => {
return {
balance: t.balance,
date: monthUtils.format(t.date, 'MMM yyyy', locale),
};
});
setBalanceData(balances);
setHoveredValue(balances[balances.length - 1]);
setLoading(false);
}
const balances = totals
.sort((a, b) => monthUtils.differenceInCalendarMonths(a.date, b.date))
.map(t => {
return {
balance: t.balance,
date: monthUtils.format(t.date, 'MMM yyyy', locale),
};
});
setBalanceData(balances);
setHoveredValue(balances[balances.length - 1]);
setLoading(false);
processData(startingBalance, monthlyTotals);
}
fetchBalanceHistory();
}, [accountId, locale]);
}, [startingBalance, monthlyTotals, locale]);
// State to track if the chart is hovered (used to conditionally render PrivacyFilter)
const [isHovered, setIsHovered] = useState(false);

View File

@@ -1,7 +1,6 @@
import React, {
type ComponentProps,
type ReactNode,
useEffect,
useRef,
useState,
} from 'react';
@@ -204,7 +203,7 @@ export function AccountHeader({
const isUsingServer = syncServerStatus !== 'no-server';
const isServerOffline = syncServerStatus === 'offline';
const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
const [showNetWorthChartPref, setShowNetWorthChartPref] = useSyncedPref(
const [showNetWorthChartPref, _setShowNetWorthChartPref] = useSyncedPref(
`show-account-${accountId}-net-worth-chart`,
);
const showNetWorthChart = showNetWorthChartPref === 'true';
@@ -233,30 +232,6 @@ export function AccountHeader({
}
const graphRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleResize = () => {
const ele = graphRef.current;
if (!ele) return;
const clone = ele.cloneNode(true) as HTMLDivElement;
Object.assign(clone.style, {
visibility: 'hidden',
display: 'flex',
});
ele.after(clone);
if (clone.clientHeight < window.innerHeight * 0.15) {
setShowNetWorthChartPref('true');
} else {
setShowNetWorthChartPref('false');
}
clone.remove();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [setShowNetWorthChartPref]);
useHotkeys(
'ctrl+f, cmd+f, meta+f',

View File

@@ -473,115 +473,127 @@ function SingleAutocomplete<T extends AutocompleteItem>({
>
<View ref={triggerRef} style={{ flexShrink: 0 }}>
{renderInput(
getInputProps({
ref: inputRef,
...inputProps,
onFocus: e => {
inputProps.onFocus?.(e);
(() => {
const { className, style, ...restInputProps } =
inputProps || {};
const downshiftProps = getInputProps({
ref: inputRef,
...restInputProps,
onFocus: e => {
inputProps.onFocus?.(e);
if (openOnFocus) {
open();
}
},
onBlur: e => {
// Should this be e.nativeEvent
e['preventDownshiftDefault'] = true;
inputProps.onBlur?.(e);
if (openOnFocus) {
open();
}
},
onBlur: e => {
// Should this be e.nativeEvent
e['preventDownshiftDefault'] = true;
inputProps.onBlur?.(e);
if (!closeOnBlur) {
return;
}
if (itemsViewRef.current?.contains(e.relatedTarget)) {
// Do not close when the user clicks on any of the items.
e.stopPropagation();
return;
}
if (clearOnBlur) {
if (e.target.value === '') {
onSelect?.(null, e.target.value);
setSelectedItem(null);
close();
if (!closeOnBlur) {
return;
}
// If not using table behavior, reset the input on blur. Tables
// handle saving the value on blur.
const value = selectedItem ? getItemId(selectedItem) : null;
resetState(value);
} else {
close();
}
},
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
const { onKeyDown } = inputProps || {};
// If the dropdown is open, an item is highlighted, and the user
// pressed enter, always capture that and handle it ourselves
if (isOpen) {
if (e.key === 'Enter') {
if (highlightedIndex != null) {
if (
inst.lastChangeType ===
Downshift.stateChangeTypes.itemMouseEnter
) {
// If the last thing the user did was hover an item, intentionally
// ignore the default behavior of selecting the item. It's too
// common to accidentally hover an item and then save it
e.preventDefault();
} else {
// Otherwise, stop propagation so that the table navigator
// doesn't handle it
e.stopPropagation();
}
} else if (!strict) {
// Handle it ourselves
e.stopPropagation();
onSelect(value, (e.target as HTMLInputElement).value);
return onSelectAfter();
} else {
// No highlighted item, still allow the table to save the item
// as `null`, even though we're allowing the table to move
e.preventDefault();
onKeyDown?.(e);
}
} else if (shouldSaveFromKey(e)) {
e.preventDefault();
onKeyDown?.(e);
}
}
// Handle escape ourselves
if (e.key === 'Escape') {
e.nativeEvent['preventDownshiftDefault'] = true;
if (!embedded) {
if (itemsViewRef.current?.contains(e.relatedTarget)) {
// Do not close when the user clicks on any of the items.
e.stopPropagation();
return;
}
fireUpdate(
onUpdate,
strict,
suggestions,
null,
getItemId(originalItem),
);
if (clearOnBlur) {
if (e.target.value === '') {
onSelect?.(null, e.target.value);
setSelectedItem(null);
close();
return;
}
setValue(getItemName(originalItem));
setSelectedItem(
findItem(strict, suggestions, originalItem),
);
setHighlightedIndex(null);
if (embedded) {
open();
// If not using table behavior, reset the input on blur. Tables
// handle saving the value on blur.
const value = selectedItem
? getItemId(selectedItem)
: null;
resetState(value);
} else {
close();
}
}
},
}),
},
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
const { onKeyDown } = inputProps || {};
// If the dropdown is open, an item is highlighted, and the user
// pressed enter, always capture that and handle it ourselves
if (isOpen) {
if (e.key === 'Enter') {
if (highlightedIndex != null) {
if (
inst.lastChangeType ===
Downshift.stateChangeTypes.itemMouseEnter
) {
// If the last thing the user did was hover an item, intentionally
// ignore the default behavior of selecting the item. It's too
// common to accidentally hover an item and then save it
e.preventDefault();
} else {
// Otherwise, stop propagation so that the table navigator
// doesn't handle it
e.stopPropagation();
}
} else if (!strict) {
// Handle it ourselves
e.stopPropagation();
onSelect(value, (e.target as HTMLInputElement).value);
return onSelectAfter();
} else {
// No highlighted item, still allow the table to save the item
// as `null`, even though we're allowing the table to move
e.preventDefault();
onKeyDown?.(e);
}
} else if (shouldSaveFromKey(e)) {
e.preventDefault();
onKeyDown?.(e);
}
}
// Handle escape ourselves
if (e.key === 'Escape') {
e.nativeEvent['preventDownshiftDefault'] = true;
if (!embedded) {
e.stopPropagation();
}
fireUpdate(
onUpdate,
strict,
suggestions,
null,
getItemId(originalItem),
);
setValue(getItemName(originalItem));
setSelectedItem(
findItem(strict, suggestions, originalItem),
);
setHighlightedIndex(null);
if (embedded) {
open();
} else {
close();
}
}
},
});
return {
...downshiftProps,
...(className && { className }),
...(style && { style }),
};
})(),
)}
</View>
{isOpen &&

View File

@@ -44,11 +44,25 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
}
function extractPayeesAndHeaderNames(screen: Screen) {
return [
...screen
.getByTestId('autocomplete')
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
]
const autocompleteElement = screen.getByTestId('autocomplete');
// Get all elements that match either selector, but query them separately
// and then sort by their position in the DOM to maintain document order
const headers = [
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR),
];
const items = [...autocompleteElement.querySelectorAll(PAYEE_SELECTOR)];
// Combine all elements and sort by their position in the DOM
const allElements = [...headers, ...items];
allElements.sort((a, b) => {
// Compare document position to maintain DOM order
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
});
return allElements
.map(e => e.getAttribute('data-testid'))
.map(firstOrIncorrect);
}
@@ -154,15 +168,9 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
];
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
[
...screen
.getByTestId('autocomplete')
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
]
.map(e => e.getAttribute('data-testid'))
.map(firstOrIncorrect),
).toStrictEqual(expectedPayeeOrder);
expect(extractPayeesAndHeaderNames(screen)).toStrictEqual(
expectedPayeeOrder,
);
});
test('list with more than the maximum favorites only lists favorites', async () => {

View File

@@ -171,34 +171,54 @@ function PayeeList({
// entered
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
return items.reduce(
(acc, item, index) => {
let currentIndex = 0;
const result = items.reduce(
(acc, item) => {
if (item.id === 'new') {
acc.newPayee = { ...item, highlightedIndex: index };
acc.newPayee = { ...item };
} else if (item.itemType === 'common_payee') {
acc.suggestedPayees.push({ ...item, highlightedIndex: index });
acc.suggestedPayees.push({ ...item });
} else if (item.itemType === 'payee') {
acc.payees.push({ ...item, highlightedIndex: index });
acc.payees.push({ ...item });
} else if (item.itemType === 'account') {
acc.transferPayees.push({ ...item, highlightedIndex: index });
acc.transferPayees.push({ ...item });
}
return acc;
},
{
newPayee: null as PayeeAutocompleteItem & {
highlightedIndex: number;
},
suggestedPayees: [] as Array<
PayeeAutocompleteItem & { highlightedIndex: number }
>,
payees: [] as Array<
PayeeAutocompleteItem & { highlightedIndex: number }
>,
transferPayees: [] as Array<
PayeeAutocompleteItem & { highlightedIndex: number }
>,
newPayee: null as PayeeAutocompleteItem | null,
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
payees: [] as Array<PayeeAutocompleteItem>,
transferPayees: [] as Array<PayeeAutocompleteItem>,
},
);
// assign indexes in render order
const newPayeeWithIndex = result.newPayee
? { ...result.newPayee, highlightedIndex: currentIndex++ }
: null;
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
return {
newPayee: newPayeeWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
// We limit the number of payees shown to 100.

View File

@@ -1,4 +1,4 @@
import React, { Fragment, type ComponentProps } from 'react';
import React, { type ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from '@actual-app/components/theme';
@@ -27,7 +27,7 @@ export function ReportList<T extends { id: string; name: string }>({
...(!embedded && { maxHeight: 175 }),
}}
>
<Fragment>{ItemHeader({ title: t('Saved Reports') })}</Fragment>
<ItemHeader title={t('Saved Reports')} />
{items.map((item, idx) => {
return [
<div

View File

@@ -9,7 +9,6 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { mapField, friendlyOp } from 'loot-core/shared/rules';
import { integerToCurrency } from 'loot-core/shared/util';
import { type RuleConditionEntity } from 'loot-core/types/models';
import { FilterEditor } from './FiltersMenu';
@@ -139,11 +138,7 @@ export function FilterExpression<T extends RuleConditionEntity>({
<FilterEditor
field={originalField}
op={op}
value={
field === 'amount' && typeof value === 'number'
? integerToCurrency(value)
: value
}
value={value}
options={options}
onSave={onChange}
onClose={() => setEditing(false)}

View File

@@ -42,6 +42,7 @@ import { updateFilterReducer } from './updateFilterReducer';
import { GenericInput } from '@desktop-client/components/util/GenericInput';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useTransactionFilters } from '@desktop-client/hooks/useTransactionFilters';
let isDatepickerClick = false;
@@ -68,6 +69,7 @@ function ConfigureField({
onApply,
}) {
const { t } = useTranslation();
const format = useFormat();
const [subfield, setSubfield] = useState(initialSubfield);
const inputRef = useRef();
const prevOp = useRef(null);
@@ -222,10 +224,33 @@ function ConfigureField({
<Form
onSubmit={e => {
e.preventDefault();
let submitValue = value;
if (field === 'amount' && inputRef.current) {
try {
if (inputRef.current.getCurrentAmount) {
submitValue = inputRef.current.getCurrentAmount();
} else {
const rawValue = inputRef.current.value || '';
const parsed = format.fromEdit(rawValue, null);
if (parsed == null) {
submitValue = value; // keep previous if parsing failed
} else {
const opts = subfieldToOptions(field, subfield);
submitValue =
opts?.inflow || opts?.outflow ? Math.abs(parsed) : parsed;
}
}
} catch {
submitValue = value;
}
}
onApply({
field,
op,
value,
value: submitValue,
options: subfieldToOptions(field, subfield),
});
}}
@@ -244,9 +269,11 @@ function ConfigureField({
? 'string'
: type
}
numberFormatType="currency"
value={formattedValue}
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
options={subfieldToOptions(field, subfield)}
style={{ marginTop: 10 }}
onChange={v => {
dispatch({ type: 'set-value', value: v });

View File

@@ -137,7 +137,7 @@ export const Checkbox = (props: CheckboxProps) => {
backgroundColor: theme.buttonNormalDisabledBorder,
},
},
'&.focus-visible:focus': {
'&:focus-visible': {
'::before': {
position: 'absolute',
top: -5,

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { Trans } from 'react-i18next';
import { Navigate, Route, Routes } from 'react-router';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
@@ -55,7 +56,10 @@ function Version() {
},
}}
>
{`App: v${window.Actual.ACTUAL_VERSION} | Server: ${version}`}
<Trans>
App: v{{ appVersion: window.Actual.ACTUAL_VERSION }} | Server:{' '}
{{ serverVersion: version }}
</Trans>
</Text>
);
}

View File

@@ -0,0 +1,140 @@
import React, { type ReactNode, useRef, useState } from 'react';
import { GridListItem, type GridListItemProps } from 'react-aria-components';
import { useSpring, animated, config } from 'react-spring';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { useDrag } from '@use-gesture/react';
import { type WithRequired } from 'loot-core/types/util';
type ActionableGridListItemProps<T> = {
actions?: ReactNode;
actionsBackgroundColor?: string;
actionsWidth?: number;
children?: ReactNode;
} & Omit<WithRequired<GridListItemProps<T>, 'value'>, 'children'>;
export function ActionableGridListItem<T extends object>({
value,
textValue,
actions,
actionsBackgroundColor = theme.errorBackground,
actionsWidth = 100,
children,
onAction,
...props
}: ActionableGridListItemProps<T>) {
const dragStartedRef = useRef(false);
const [isRevealed, setIsRevealed] = useState(false);
const hasActions = !!actions;
// Spring animation for the swipe
const [{ x }, api] = useSpring(() => ({
x: 0,
config: config.stiff,
}));
// Handle drag gestures
const bind = useDrag(
({ active, movement: [mx], velocity: [vx] }) => {
const startPos = isRevealed ? -actionsWidth : 0;
const currentX = startPos + mx;
if (active) {
dragStartedRef.current = true;
api.start({
x: Math.max(-actionsWidth, Math.min(0, currentX)),
onRest: () => {
dragStartedRef.current = false;
},
});
return;
}
// Snap to revealed (-actionsWidth) or closed (0) based on position and velocity
const shouldReveal =
currentX < -actionsWidth / 2 ||
(vx < -0.5 && currentX < -actionsWidth / 5);
api.start({
x: shouldReveal ? -actionsWidth : 0,
onRest: () => {
dragStartedRef.current = false;
setIsRevealed(shouldReveal);
},
});
},
{
axis: 'x',
from: () => [isRevealed ? -actionsWidth : 0, 0],
enabled: hasActions,
},
);
// Prevent onAction from firing when dragging or if a drag was started
const handleAction = () => {
// Only allow action if no drag was started
if (!dragStartedRef.current) {
onAction?.();
}
};
return (
<GridListItem
{...props}
value={value}
textValue={textValue}
style={{
...styles.mobileListItem,
padding: 0,
backgroundColor: hasActions
? actionsBackgroundColor
: (styles.mobileListItem.backgroundColor ?? 'transparent'),
overflow: 'hidden',
}}
>
<animated.div
{...(hasActions ? bind() : {})}
style={{
...(hasActions
? { transform: x.to(v => `translate3d(${v}px,0,0)`) }
: {}),
display: 'flex',
touchAction: hasActions ? 'pan-y' : 'auto',
cursor: hasActions ? 'grab' : 'pointer',
}}
>
{/* Main content */}
<div
style={{
display: 'flex',
alignItems: 'center',
flex: 1,
backgroundColor: theme.tableBackground,
minWidth: '100%',
padding: 16,
}}
onClick={handleAction}
>
{children}
</div>
{/* Actions that appear when swiped */}
{hasActions && (
<div
style={{
display: 'flex',
justifyContent: 'center',
backgroundColor: actionsBackgroundColor,
minWidth: actionsWidth,
}}
>
{actions}
</div>
)}
</animated.div>
</GridListItem>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
@@ -14,15 +14,20 @@ import { PayeesList } from './PayeesList';
import { Search } from '@desktop-client/components/common/Search';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useSelector } from '@desktop-client/redux';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
export function MobilePayeesPage() {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const payees = usePayees();
const { showUndoNotification } = useUndo();
const [filter, setFilter] = useState('');
const [ruleCounts, setRuleCounts] = useState(new Map<string, number>());
const { ruleCounts, isLoading: isRuleCountsLoading } = usePayeeRuleCounts();
const isLoading = useSelector(
s => s.payees.isPayeesLoading || s.payees.isCommonPayeesLoading,
);
@@ -33,16 +38,6 @@ export function MobilePayeesPage() {
return payees.filter(p => getNormalisedString(p.name).includes(norm));
}, [payees, filter]);
const refetchRuleCounts = useCallback(async () => {
const counts = await send('payees-get-rule-counts');
const countsMap = new Map(Object.entries(counts));
setRuleCounts(countsMap);
}, []);
useEffect(() => {
refetchRuleCounts();
}, [refetchRuleCounts]);
const onSearchChange = useCallback((value: string) => {
setFilter(value);
}, []);
@@ -85,6 +80,30 @@ export function MobilePayeesPage() {
[navigate, ruleCounts],
);
const handlePayeeDelete = useCallback(
async (payee: PayeeEntity) => {
try {
await send('payees-batch-change', { deleted: [{ id: payee.id }] });
showUndoNotification({
message: t('Payee “{{name}}” deleted successfully', {
name: payee.name,
}),
});
} catch (error) {
console.error('Failed to delete payee:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete payee. Please try again.'),
},
}),
);
}
},
[dispatch, showUndoNotification, t],
);
return (
<Page header={<MobilePageHeader title={t('Payees')} />} padding={0}>
<View
@@ -114,8 +133,10 @@ export function MobilePayeesPage() {
<PayeesList
payees={filteredPayees}
ruleCounts={ruleCounts}
isRuleCountsLoading={isRuleCountsLoading}
isLoading={isLoading}
onPayeePress={handlePayeePress}
onPayeeDelete={handlePayeeDelete}
/>
</Page>
);

View File

@@ -1,4 +1,5 @@
import { Trans } from 'react-i18next';
import { GridList } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { Text } from '@actual-app/components/text';
@@ -14,16 +15,22 @@ import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTa
type PayeesListProps = {
payees: PayeeEntity[];
ruleCounts: Map<string, number>;
isRuleCountsLoading?: boolean;
isLoading?: boolean;
onPayeePress: (payee: PayeeEntity) => void;
onPayeeDelete: (payee: PayeeEntity) => void;
};
export function PayeesList({
payees,
ruleCounts,
isRuleCountsLoading = false,
isLoading = false,
onPayeePress,
onPayeeDelete,
}: PayeesListProps) {
const { t } = useTranslation();
if (isLoading && payees.length === 0) {
return (
<View
@@ -63,22 +70,33 @@ export function PayeesList({
}
return (
<View
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
>
{payees.map(payee => (
<PayeesListItem
key={payee.id}
payee={payee}
ruleCount={ruleCounts.get(payee.id) ?? 0}
onPress={() => onPayeePress(payee)}
/>
))}
<View style={{ flex: 1 }}>
<GridList
aria-label={t('Payees')}
aria-busy={isLoading || undefined}
items={payees}
style={{
flex: 1,
paddingBottom: MOBILE_NAV_HEIGHT,
overflow: 'auto',
}}
dependencies={[ruleCounts, isRuleCountsLoading]}
>
{payee => (
<PayeesListItem
value={payee}
ruleCount={ruleCounts.get(payee.id) ?? 0}
isRuleCountLoading={isRuleCountsLoading}
onAction={() => onPayeePress(payee)}
onDelete={() => onPayeeDelete(payee)}
/>
)}
</GridList>
{isLoading && (
<View
style={{
alignItems: 'center',
paddingVertical: 20,
paddingTop: 20,
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import React, { memo } from 'react';
import { type GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgBookmark } from '@actual-app/components/icons/v1';
@@ -7,88 +8,108 @@ import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { type PayeeEntity } from 'loot-core/types/models';
import { type WithRequired } from 'loot-core/types/util';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { PayeeRuleCountLabel } from '@desktop-client/components/payees/PayeeRuleCountLabel';
type PayeesListItemProps = {
payee: PayeeEntity;
ruleCount: number;
onPress: () => void;
};
isRuleCountLoading?: boolean;
onDelete: () => void;
} & WithRequired<GridListItemProps<PayeeEntity>, 'value'>;
export function PayeesListItem({
payee,
export const PayeesListItem = memo(function PayeeListItem({
value: payee,
ruleCount,
onPress,
isRuleCountLoading,
onDelete,
...props
}: PayeesListItemProps) {
const { t } = useTranslation();
return (
<Button
variant="bare"
style={{
minHeight: 56,
width: '100%',
borderRadius: 0,
borderWidth: '0 0 1px 0',
borderColor: theme.tableBorder,
borderStyle: 'solid',
backgroundColor: theme.tableBackground,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
padding: '12px 16px',
gap: 5,
}}
onPress={onPress}
>
{payee.favorite && (
<SvgBookmark
width={15}
height={15}
style={{
color: theme.pageText,
flexShrink: 0,
}}
/>
)}
<SpaceBetween
style={{
justifyContent: 'space-between',
flex: 1,
alignItems: 'flex-start',
}}
>
<span
style={{
fontSize: 15,
fontWeight: 500,
color: payee.transfer_acct ? theme.pageTextSubdued : theme.pageText,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
textAlign: 'left',
}}
title={payee.name}
>
{(payee.transfer_acct ? t('Transfer: ') : '') + payee.name}
</span>
const label = payee.transfer_acct
? t('Transfer: {{name}}', { name: payee.name })
: payee.name;
<span
return (
<ActionableGridListItem
id={payee.id}
value={payee}
textValue={label}
actions={
!payee.transfer_acct && (
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
width: '100%',
}}
>
<Trans>Delete</Trans>
</Button>
)
}
{...props}
>
<SpaceBetween gap={5} style={{ flex: 1 }}>
{payee.favorite && (
<SvgBookmark
aria-hidden
focusable={false}
width={15}
height={15}
style={{
color: theme.pageText,
flexShrink: 0,
}}
/>
)}
<SpaceBetween
style={{
borderRadius: 4,
padding: '3px 6px',
backgroundColor: theme.noticeBackground,
border: '1px solid ' + theme.noticeBackground,
color: theme.noticeTextDark,
fontSize: 12,
flexShrink: 0,
justifyContent: 'space-between',
flex: 1,
alignItems: 'flex-start',
}}
>
<PayeeRuleCountLabel count={ruleCount} style={{ fontSize: 12 }} />
</span>
<span
style={{
fontSize: 15,
fontWeight: 500,
color: payee.transfer_acct
? theme.pageTextSubdued
: theme.pageText,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
textAlign: 'left',
}}
title={label}
>
{label}
</span>
<span
style={{
borderRadius: 4,
padding: '3px 6px',
backgroundColor: theme.noticeBackground,
border: '1px solid ' + theme.noticeBackground,
color: theme.noticeTextDark,
fontSize: 12,
flexShrink: 0,
}}
>
<PayeeRuleCountLabel
count={ruleCount}
isLoading={isRuleCountLoading}
style={{ fontSize: 12 }}
/>
</span>
</SpaceBetween>
</SpaceBetween>
</Button>
</ActionableGridListItem>
);
}
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -7,12 +7,14 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import { q } from 'loot-core/shared/query';
import { type RuleEntity, type NewRuleEntity } from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { RuleEditor } from '@desktop-client/components/rules/RuleEditor';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -27,6 +29,21 @@ export function MobileRuleEditPage() {
const [rule, setRule] = useState<RuleEntity | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { schedules = [] } = useSchedules({
query: useMemo(
() =>
rule?.id
? q('schedules')
.filter({ rule: rule.id, completed: false })
.select('*')
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule
[rule?.id],
),
});
// Check if the current rule is linked to a schedule
const isLinkedToSchedule = schedules.length > 0;
// Load rule by ID if we're in edit mode
useEffect(() => {
if (id && id !== 'new') {
@@ -174,7 +191,7 @@ export function MobileRuleEditPage() {
rule={defaultRule}
onSave={handleSave}
onCancel={handleCancel}
onDelete={isEditing ? handleDelete : undefined}
onDelete={isEditing && !isLinkedToSchedule ? handleDelete : undefined}
style={{
paddingTop: 10,
flex: 1,

View File

@@ -5,7 +5,8 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import { send, listen } from 'loot-core/platform/client/fetch';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
import { type RuleEntity } from 'loot-core/types/models';
@@ -21,17 +22,19 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
const PAGE_SIZE = 50;
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function MobileRulesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const [visibleRulesParam] = useUrlParam('visible-rules');
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [hasMoreRules, setHasMoreRules] = useState(true);
const [filter, setFilter] = useState('');
const { schedules = [] } = useSchedules({
@@ -76,16 +79,12 @@ export function MobileRulesPage() {
);
}, [visibleRules, filter, filterData, schedules]);
const loadRules = useCallback(async (append = false) => {
const loadRules = useCallback(async () => {
try {
setIsLoading(true);
const result = await send('rules-get');
const newRules = result || [];
setAllRules(prevRules =>
append ? [...prevRules, ...newRules] : newRules,
);
setHasMoreRules(newRules.length === PAGE_SIZE);
const rules = result || [];
setAllRules(rules);
} catch (error) {
console.error('Failed to load rules:', error);
setAllRules([]);
@@ -98,6 +97,20 @@ export function MobileRulesPage() {
loadRules();
}, [loadRules]);
// Listen for undo events to refresh rules list
useEffect(() => {
const onUndo = () => {
loadRules();
};
const lastUndoEvent = undo.getUndoState('undoEvent');
if (lastUndoEvent) {
onUndo();
}
return listen('undo-event', onUndo);
}, [loadRules]);
const handleRulePress = useCallback(
(rule: RuleEntity) => {
navigate(`/rules/${rule.id}`);
@@ -105,12 +118,6 @@ export function MobileRulesPage() {
[navigate],
);
const handleLoadMore = useCallback(() => {
if (!isLoading && hasMoreRules && !filter) {
loadRules(true);
}
}, [isLoading, hasMoreRules, filter, loadRules]);
const onSearchChange = useCallback(
(value: string) => {
setFilter(value);
@@ -118,6 +125,47 @@ export function MobileRulesPage() {
[setFilter],
);
const handleRuleDelete = useCallback(
async (rule: RuleEntity) => {
try {
const { someDeletionsFailed } = await send('rule-delete-all', [
rule.id,
]);
if (someDeletionsFailed) {
dispatch(
addNotification({
notification: {
type: 'warning',
message: t(
'This rule could not be deleted because it is linked to a schedule.',
),
},
}),
);
} else {
showUndoNotification({
message: t('Rule deleted successfully'),
});
}
// Refresh the rules list
await loadRules();
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete rule. Please try again.'),
},
}),
);
}
},
[dispatch, showUndoNotification, t, loadRules],
);
return (
<Page
header={
@@ -153,7 +201,7 @@ export function MobileRulesPage() {
rules={filteredRules}
isLoading={isLoading}
onRulePress={handleRulePress}
onLoadMore={handleLoadMore}
onRuleDelete={handleRuleDelete}
/>
</Page>
);

View File

@@ -1,4 +1,4 @@
import { type UIEvent } from 'react';
import { GridList } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
@@ -16,14 +16,14 @@ type RulesListProps = {
rules: RuleEntity[];
isLoading: boolean;
onRulePress: (rule: RuleEntity) => void;
onLoadMore?: () => void;
onRuleDelete: (rule: RuleEntity) => void;
};
export function RulesList({
rules,
isLoading,
onRulePress,
onLoadMore,
onRuleDelete,
}: RulesListProps) {
const { t } = useTranslation();
@@ -65,32 +65,31 @@ export function RulesList({
);
}
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (!onLoadMore) return;
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
onLoadMore();
}
};
return (
<View
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
onScroll={handleScroll}
>
{rules.map(rule => (
<RulesListItem
key={rule.id}
rule={rule}
onPress={() => onRulePress(rule)}
/>
))}
<View style={{ flex: 1 }}>
<GridList
aria-label={t('Rules')}
aria-busy={isLoading || undefined}
items={rules}
style={{
flex: 1,
paddingBottom: MOBILE_NAV_HEIGHT,
overflow: 'auto',
}}
>
{rule => (
<RulesListItem
value={rule}
onAction={() => onRulePress(rule)}
onDelete={() => onRuleDelete(rule)}
/>
)}
</GridList>
{isLoading && (
<View
style={{
alignItems: 'center',
paddingVertical: 20,
paddingTop: 20,
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />

View File

@@ -1,24 +1,31 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { type GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { type RuleEntity } from 'loot-core/types/models';
import { type WithRequired } from 'loot-core/types/util';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { ActionExpression } from '@desktop-client/components/rules/ActionExpression';
import { ConditionExpression } from '@desktop-client/components/rules/ConditionExpression';
import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
const ROW_HEIGHT = 60;
type RulesListItemProps = {
rule: RuleEntity;
onPress: () => void;
};
onDelete: () => void;
} & WithRequired<GridListItemProps<RuleEntity>, 'value'>;
export function RulesListItem({ rule, onPress }: RulesListItemProps) {
export function RulesListItem({
value: rule,
onDelete,
style,
...props
}: RulesListItemProps) {
const { t } = useTranslation();
// Group actions by splitIndex to handle split transactions
@@ -26,172 +33,172 @@ export function RulesListItem({ rule, onPress }: RulesListItemProps) {
const hasSplits = actionSplits.length > 1;
return (
<Button
variant="bare"
style={{
minHeight: ROW_HEIGHT,
width: '100%',
borderRadius: 0,
borderWidth: '0 0 1px 0',
borderColor: theme.tableBorder,
borderStyle: 'solid',
backgroundColor: theme.tableBackground,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'flex-start',
padding: '8px 16px',
gap: 12,
}}
onPress={onPress}
<ActionableGridListItem
id={rule.id}
value={rule}
textValue={t('Rule {{id}}', { id: rule.id })}
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
actions={
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
width: '100%',
}}
>
<Trans>Delete</Trans>
</Button>
}
{...props}
>
{/* Column 1: PRE/POST pill */}
<View
style={{
flexShrink: 0,
paddingTop: 2, // Slight top padding to align with text baseline
}}
>
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
{/* Column 1: PRE/POST pill */}
<View
style={{
backgroundColor:
rule.stage === 'pre'
? theme.noticeBackgroundLight
: rule.stage === 'post'
? theme.warningBackground
: theme.pillBackgroundSelected,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 2,
paddingBottom: 2,
borderRadius: 3,
flexShrink: 0,
paddingTop: 2, // Slight top padding to align with text baseline
}}
>
<span
<View
style={{
fontSize: 11,
fontWeight: 500,
color:
backgroundColor:
rule.stage === 'pre'
? theme.noticeTextLight
? theme.noticeBackgroundLight
: rule.stage === 'post'
? theme.warningText
: theme.pillTextSelected,
? theme.warningBackground
: theme.pillBackgroundSelected,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 2,
paddingBottom: 2,
borderRadius: 3,
}}
>
{rule.stage === 'pre'
? t('PRE')
: rule.stage === 'post'
? t('POST')
: t('DEFAULT')}
</span>
</View>
</View>
{/* Column 2: IF and THEN blocks */}
<View
style={{
flex: 1,
flexDirection: 'column',
gap: 4,
}}
>
{/* IF conditions block */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginRight: 4,
}}
>
{t('IF')}
</span>
{rule.conditions.map((condition, index) => (
<View key={index} style={{ marginRight: 4, marginBottom: 2 }}>
<ConditionExpression
field={condition.field}
op={condition.op}
value={condition.value}
options={condition.options}
inline={true}
/>
</View>
))}
<span
style={{
fontSize: 11,
fontWeight: 500,
color:
rule.stage === 'pre'
? theme.noticeTextLight
: rule.stage === 'post'
? theme.warningText
: theme.pillTextSelected,
}}
data-testid="rule-stage-badge"
>
{rule.stage === 'pre'
? t('PRE')
: rule.stage === 'post'
? t('POST')
: t('DEFAULT')}
</span>
</View>
</View>
{/* THEN actions block */}
{/* Column 2: IF and THEN blocks */}
<View
style={{
flex: 1,
flexDirection: 'column',
alignItems: 'flex-start',
gap: 4,
}}
>
<span
{/* IF conditions block */}
<SpaceBetween gap={6}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginRight: 4,
}}
>
{t('IF')}
</span>
{rule.conditions.map((condition, index) => (
<View key={index} style={{ marginRight: 4, marginBottom: 2 }}>
<ConditionExpression
field={condition.field}
op={condition.op}
value={condition.value}
options={condition.options}
inline={true}
/>
</View>
))}
</SpaceBetween>
{/* THEN actions block */}
<View
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginBottom: 2,
flexDirection: 'column',
alignItems: 'flex-start',
gap: 4,
}}
>
{t('THEN')}
</span>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginBottom: 2,
}}
>
{t('THEN')}
</span>
{hasSplits
? actionSplits.map((split, i) => (
<View
key={split.id}
style={{
width: '100%',
flexDirection: 'column',
alignItems: 'flex-start',
marginTop: i > 0 ? 4 : 0,
padding: '6px',
borderColor: theme.tableBorder,
borderWidth: '1px',
borderRadius: '5px',
}}
>
<span
{hasSplits
? actionSplits.map((split, i) => (
<View
key={i}
style={{
fontSize: 11,
fontWeight: 500,
color: theme.pageTextLight,
marginBottom: 4,
width: '100%',
flexDirection: 'column',
alignItems: 'flex-start',
marginTop: i > 0 ? 4 : 0,
padding: '6px',
borderColor: theme.tableBorder,
borderWidth: '1px',
borderRadius: '5px',
}}
>
{i ? t('Split {{num}}', { num: i }) : t('Apply to all')}
</span>
{split.actions.map((action, j) => (
<View
key={j}
<span
style={{
marginBottom: j !== split.actions.length - 1 ? 2 : 0,
maxWidth: '100%',
fontSize: 11,
fontWeight: 500,
color: theme.pageTextLight,
marginBottom: 4,
}}
>
<ActionExpression {...action} />
</View>
))}
</View>
))
: rule.actions.map((action, index) => (
<View key={index} style={{ marginBottom: 2, maxWidth: '100%' }}>
<ActionExpression {...action} />
</View>
))}
{i ? t('Split {{num}}', { num: i }) : t('Apply to all')}
</span>
{split.actions.map((action, j) => (
<View
key={j}
style={{
marginBottom: j !== split.actions.length - 1 ? 2 : 0,
maxWidth: '100%',
}}
>
<ActionExpression {...action} />
</View>
))}
</View>
))
: rule.actions.map((action, index) => (
<View
key={index}
style={{ marginBottom: 2, maxWidth: '100%' }}
>
<ActionExpression {...action} />
</View>
))}
</View>
</View>
</View>
</Button>
</SpaceBetween>
</ActionableGridListItem>
);
}

View File

@@ -203,7 +203,6 @@ export function TransactionList({
items={section.transactions.filter(
t => !isPreviewId(t.id) || !t.is_child,
)}
addIdAndValue
>
{transaction => (
<TransactionListItem

View File

@@ -71,8 +71,9 @@ const getScheduleIconStyle = ({ isPreview }: { isPreview: boolean }) => ({
color: isPreview ? theme.pageTextLight : theme.menuItemText,
});
type TransactionListItemProps = ComponentPropsWithoutRef<
typeof ListBoxItem<TransactionEntity>
type TransactionListItemProps = Omit<
ComponentPropsWithoutRef<typeof ListBoxItem<TransactionEntity>>,
'onPress'
> & {
onPress: (transaction: TransactionEntity) => void;
onLongPress: (transaction: TransactionEntity) => void;

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