Compare commits

..

2 Commits

Author SHA1 Message Date
Cursor Agent
14ff32a31c Update submodule to latest commit
Co-authored-by: matiss <matiss@mja.lv>
2025-09-26 21:06:53 +00:00
Cursor Agent
52b295c81c Checkpoint before follow-up message
Co-authored-by: matiss <matiss@mja.lv>
2025-09-26 19:17:02 +00:00
314 changed files with 5396 additions and 86787 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 types over interfaces.
- Use TypeScript for all code; prefer interfaces over types.
- 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

@@ -32,7 +32,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.52.0-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.55.1-jammy
image: mcr.microsoft.com/playwright:v1.52.0-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.55.1-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment

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.55.1-jammy
image: mcr.microsoft.com/playwright:v1.52.0-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,8 +26,6 @@ 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

View File

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

1
actual Submodule

Submodule actual added at 60b64e10ff

View File

@@ -16,7 +16,6 @@ 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,7 +41,6 @@ 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.55.1-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -74,27 +74,27 @@ 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/',
@@ -763,18 +763,6 @@ export default pluginTypescript.config(
'react-hooks/exhaustive-deps': 'off',
},
},
{
files: ['**/*.cjs'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
files: ['**/manifest.ts'],
rules: {
'import/no-default-export': 'off',
},
},
{
files: [
'eslint.config.mjs',

View File

@@ -23,20 +23,17 @@
"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": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
"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": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"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",
@@ -47,7 +44,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 -m ./packages/loot-core",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -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",
@@ -58,32 +55,32 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.18.8",
"@types/node": "^22.17.0",
"@types/prompts": "^2.4.9",
"@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",
"@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",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-react-hooks": "^6.0.0-rc.2",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lint-staged": "^16.2.3",
"lint-staged": "^15.5.2",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"prettier": "^3.6.2",
"prettier": "^3.5.3",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {

View File

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

7
packages/api/utils.js Normal file
View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,14 +8,14 @@
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.13.0",
"react-aria-components": "^1.8.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"@types/react": "^19.1.12",
"react": "19.1.1",
"react-dom": "19.1.1",
"vitest": "^3.2.4"
},
"exports": {

View File

@@ -154,10 +154,4 @@ 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": "^13.0.0"
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-4",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.9.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}

View File

@@ -14,9 +14,6 @@ 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.55.1-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
# Use the ip and port noted earlier

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 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: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -155,8 +155,6 @@ 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);
@@ -165,14 +163,6 @@ 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: 93 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -7,6 +7,7 @@
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" />
@@ -107,6 +108,10 @@
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.10.0",
"version": "25.9.0",
"license": "MIT",
"files": [
"build"
@@ -8,36 +8,38 @@
"devDependencies": {
"@actual-app/components": "workspace:*",
"@emotion/css": "^11.13.5",
"@fontsource/redacted-script": "^5.2.8",
"@fontsource/redacted-script": "^5.2.5",
"@juggle/resize-observer": "^3.4.0",
"@playwright/test": "1.55.1",
"@playwright/test": "1.52.0",
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.13.5",
"@swc/core": "^1.11.24",
"@swc/helpers": "^0.5.17",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@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.2.0",
"@types/react-dom": "^19.2.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react-grid-layout": "^1",
"@types/react-modal": "^3.16.3",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-react": "^5.0.4",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-react": "^5.0.2",
"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": "^10.1.0",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"downshift": "9.0.10",
"i18next": "^25.5.3",
"downshift": "7.6.2",
"focus-visible": "^4.1.5",
"i18next": "^25.2.1",
"i18next-parser": "^9.3.0",
"i18next-resources-to-backend": "^1.2.1",
"inter-ui": "^3.19.3",
"jsdom": "^27.0.0",
"jsdom": "^26.1.0",
"lodash": "^4.17.21",
"loot-core": "workspace:*",
"mdast-util-newline-to-break": "^2.0.0",
@@ -46,34 +48,35 @@
"promise-retry": "^2.0.1",
"prop-types": "^15.8.1",
"re-resizable": "^6.11.2",
"react": "19.2.0",
"react-aria": "^3.44.0",
"react-aria-components": "^1.13.0",
"react": "19.1.1",
"react-aria": "^3.39.0",
"react-aria-components": "^1.8.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"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-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-markdown": "^10.1.0",
"react-modal": "3.16.3",
"react-redux": "^9.2.0",
"react-router": "7.9.3",
"react-router": "7.6.2",
"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": "^6.0.4",
"sass": "^1.93.2",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.89.0",
"usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"vite": "^7.1.9",
"vite-plugin-pwa": "^1.0.3",
"vite-tsconfig-paths": "^5.1.4",
"uuid": "^11.1.0",
"vite": "^6.3.6",
"vite-plugin-pwa": "^1.0.0",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^3.2.4",
"xml2js": "^0.6.2"
},

View File

@@ -313,41 +313,6 @@ 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,6 +178,7 @@ global.Actual = {
// Wait for the app to reload
await new Promise(() => {});
},
updateAppMenu: () => {},
ipcConnect: () => {},
getServerSocket: async () => {
@@ -190,3 +191,35 @@ 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,28 +212,10 @@ 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;
},
{
@@ -251,12 +233,6 @@ 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;
},
);
@@ -580,7 +556,6 @@ export const getCategoriesById = memoizeOne(
res[cat.id] = cat;
});
});
return res;
},
);
@@ -609,12 +584,6 @@ 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,6 +135,10 @@ 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('?', () => setMenuOpen(true), { useKey: true });
useHotkeys('shift+?', () => setMenuOpen(true));
return (
<SpaceBetween>

View File

@@ -8,7 +8,6 @@ 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';
@@ -184,9 +183,6 @@ 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,14 +35,12 @@ 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 as ThemeIconKey] || SvgSun;
const Icon = themeIcons[theme] || SvgSun;
if (isNarrowWidth) {
return null;

View File

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

View File

@@ -20,67 +20,38 @@ import { useDispatch } from '@desktop-client/redux';
function useErrorMessage() {
const { t } = useTranslation();
function getErrorMessage(type: string, code: string) {
// Handle standardized bank sync error codes
switch (code.toUpperCase()) {
case 'INVALID_CREDENTIALS':
return t(
'Your credentials are invalid. Please reconfigure your bank connection.',
);
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;
case 'INVALID_ACCESS_TOKEN':
return t(
'Your access token is no longer valid. 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 '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. 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.',
);
return t('Rate limit exceeded for this item. Please try again later.');
case 'TIMED_OUT':
return t('The request timed out. Please try again later.');
// Legacy error codes for backwards compatibility
case 'NO_ACCOUNTS':
case 'INVALID_ACCESS_TOKEN':
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.',
'Your SimpleFIN Access Token is no longer valid. Please reset and generate a new token.',
);
case 'ACCOUNT_NEEDS_ATTENTION':
@@ -100,22 +71,9 @@ 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 reconfigure your connection, or get{' '}
An internal error occurred. Try to log in again, 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 { liveQuery } from '@desktop-client/queries/liveQuery';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
const LABEL_WIDTH = 70;
@@ -32,6 +32,11 @@ type BalanceHistoryGraphProps = {
ref?: Ref<HTMLDivElement>;
};
type Balance = {
date: string;
balance: number;
};
export function BalanceHistoryGraph({
accountId,
style,
@@ -46,11 +51,6 @@ 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,70 +65,7 @@ export function BalanceHistoryGraph({
);
useEffect(() => {
// 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) {
async function fetchBalanceHistory() {
const endDate = new Date();
const startDate = subMonths(endDate, 12);
const months = eachMonthOfInterval({
@@ -136,70 +73,99 @@ export function BalanceHistoryGraph({
end: endDate,
}).map(m => format(m, 'yyyy-MM'));
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;
});
const [starting, totals]: [number, Balance[]] = await Promise.all([
aqlQuery(
query
.transactions(accountId)
.filter({
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
})
.calculate({ $sum: '$amount' }),
).then(({ data }) => data),
// 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 =>
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: 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);
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,
});
}
});
}
processData(startingBalance, monthlyTotals);
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);
}
}, [startingBalance, monthlyTotals, locale]);
fetchBalanceHistory();
}, [accountId, locale]);
// State to track if the chart is hovered (used to conditionally render PrivacyFilter)
const [isHovered, setIsHovered] = useState(false);

View File

@@ -1,6 +1,7 @@
import React, {
type ComponentProps,
type ReactNode,
useEffect,
useRef,
useState,
} from 'react';
@@ -203,7 +204,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';
@@ -232,6 +233,30 @@ 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,127 +473,115 @@ function SingleAutocomplete<T extends AutocompleteItem>({
>
<View ref={triggerRef} style={{ flexShrink: 0 }}>
{renderInput(
(() => {
const { className, style, ...restInputProps } =
inputProps || {};
const downshiftProps = getInputProps({
ref: inputRef,
...restInputProps,
onFocus: e => {
inputProps.onFocus?.(e);
getInputProps({
ref: inputRef,
...inputProps,
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 (!closeOnBlur) {
return;
}
if (itemsViewRef.current?.contains(e.relatedTarget)) {
// Do not close when the user clicks on any of the items.
e.stopPropagation();
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();
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 {
if (clearOnBlur) {
if (e.target.value === '') {
onSelect?.(null, e.target.value);
setSelectedItem(null);
close();
return;
}
},
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
// 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();
onKeyDown?.(e);
} else {
// Otherwise, stop propagation so that the table navigator
// doesn't handle it
e.stopPropagation();
}
} else if (shouldSaveFromKey(e)) {
} 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();
}
// Handle escape ourselves
if (e.key === 'Escape') {
e.nativeEvent['preventDownshiftDefault'] = true;
fireUpdate(
onUpdate,
strict,
suggestions,
null,
getItemId(originalItem),
);
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();
}
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,25 +44,11 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
}
function extractPayeesAndHeaderNames(screen: Screen) {
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
return [
...screen
.getByTestId('autocomplete')
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
]
.map(e => e.getAttribute('data-testid'))
.map(firstOrIncorrect);
}
@@ -168,9 +154,15 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
];
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(extractPayeesAndHeaderNames(screen)).toStrictEqual(
expectedPayeeOrder,
);
expect(
[
...screen
.getByTestId('autocomplete')
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
]
.map(e => e.getAttribute('data-testid'))
.map(firstOrIncorrect),
).toStrictEqual(expectedPayeeOrder);
});
test('list with more than the maximum favorites only lists favorites', async () => {

View File

@@ -171,54 +171,34 @@ function PayeeList({
// entered
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
let currentIndex = 0;
const result = items.reduce(
(acc, item) => {
return items.reduce(
(acc, item, index) => {
if (item.id === 'new') {
acc.newPayee = { ...item };
acc.newPayee = { ...item, highlightedIndex: index };
} else if (item.itemType === 'common_payee') {
acc.suggestedPayees.push({ ...item });
acc.suggestedPayees.push({ ...item, highlightedIndex: index });
} else if (item.itemType === 'payee') {
acc.payees.push({ ...item });
acc.payees.push({ ...item, highlightedIndex: index });
} else if (item.itemType === 'account') {
acc.transferPayees.push({ ...item });
acc.transferPayees.push({ ...item, highlightedIndex: index });
}
return acc;
},
{
newPayee: null as PayeeAutocompleteItem | null,
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
payees: [] as Array<PayeeAutocompleteItem>,
transferPayees: [] as Array<PayeeAutocompleteItem>,
newPayee: null as PayeeAutocompleteItem & {
highlightedIndex: number;
},
suggestedPayees: [] as Array<
PayeeAutocompleteItem & { highlightedIndex: number }
>,
payees: [] as Array<
PayeeAutocompleteItem & { highlightedIndex: number }
>,
transferPayees: [] as Array<
PayeeAutocompleteItem & { highlightedIndex: number }
>,
},
);
// 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, { type ComponentProps } from 'react';
import React, { Fragment, 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 }),
}}
>
<ItemHeader title={t('Saved Reports')} />
<Fragment>{ItemHeader({ title: t('Saved Reports') })}</Fragment>
{items.map((item, idx) => {
return [
<div

View File

@@ -9,6 +9,7 @@ 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';
@@ -138,7 +139,11 @@ export function FilterExpression<T extends RuleConditionEntity>({
<FilterEditor
field={originalField}
op={op}
value={value}
value={
field === 'amount' && typeof value === 'number'
? integerToCurrency(value)
: value
}
options={options}
onSave={onChange}
onClose={() => setEditing(false)}

View File

@@ -42,7 +42,6 @@ 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;
@@ -69,7 +68,6 @@ function ConfigureField({
onApply,
}) {
const { t } = useTranslation();
const format = useFormat();
const [subfield, setSubfield] = useState(initialSubfield);
const inputRef = useRef();
const prevOp = useRef(null);
@@ -224,33 +222,10 @@ 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: submitValue,
value,
options: subfieldToOptions(field, subfield),
});
}}
@@ -269,11 +244,9 @@ 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-visible:focus': {
'::before': {
position: 'absolute',
top: -5,

View File

@@ -1,5 +1,4 @@
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';
@@ -56,10 +55,7 @@ function Version() {
},
}}
>
<Trans>
App: v{{ appVersion: window.Actual.ACTUAL_VERSION }} | Server:{' '}
{{ serverVersion: version }}
</Trans>
{`App: v${window.Actual.ACTUAL_VERSION} | Server: ${version}`}
</Text>
);
}

View File

@@ -1,140 +0,0 @@
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, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
@@ -14,20 +14,15 @@ 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 { useUndo } from '@desktop-client/hooks/useUndo';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { 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, isLoading: isRuleCountsLoading } = usePayeeRuleCounts();
const [ruleCounts, setRuleCounts] = useState(new Map<string, number>());
const isLoading = useSelector(
s => s.payees.isPayeesLoading || s.payees.isCommonPayeesLoading,
);
@@ -38,6 +33,16 @@ 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);
}, []);
@@ -80,30 +85,6 @@ 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
@@ -133,10 +114,8 @@ export function MobilePayeesPage() {
<PayeesList
payees={filteredPayees}
ruleCounts={ruleCounts}
isRuleCountsLoading={isRuleCountsLoading}
isLoading={isLoading}
onPayeePress={handlePayeePress}
onPayeeDelete={handlePayeeDelete}
/>
</Page>
);

View File

@@ -1,5 +1,4 @@
import { GridList } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Trans } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { Text } from '@actual-app/components/text';
@@ -15,22 +14,16 @@ 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
@@ -70,33 +63,22 @@ export function PayeesList({
}
return (
<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>
<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)}
/>
))}
{isLoading && (
<View
style={{
alignItems: 'center',
paddingTop: 20,
paddingVertical: 20,
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />

View File

@@ -1,6 +1,5 @@
import React, { memo } from 'react';
import { type GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgBookmark } from '@actual-app/components/icons/v1';
@@ -8,108 +7,88 @@ 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;
isRuleCountLoading?: boolean;
onDelete: () => void;
} & WithRequired<GridListItemProps<PayeeEntity>, 'value'>;
onPress: () => void;
};
export const PayeesListItem = memo(function PayeeListItem({
value: payee,
export function PayeesListItem({
payee,
ruleCount,
isRuleCountLoading,
onDelete,
...props
onPress,
}: PayeesListItemProps) {
const { t } = useTranslation();
const label = payee.transfer_acct
? t('Transfer: {{name}}', { name: payee.name })
: payee.name;
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}
<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}
>
<SpaceBetween gap={5} style={{ flex: 1 }}>
{payee.favorite && (
<SvgBookmark
aria-hidden
focusable={false}
width={15}
height={15}
style={{
color: theme.pageText,
flexShrink: 0,
}}
/>
)}
<SpaceBetween
{payee.favorite && (
<SvgBookmark
width={15}
height={15}
style={{
justifyContent: 'space-between',
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,
alignItems: 'flex-start',
textAlign: 'left',
}}
title={payee.name}
>
{(payee.transfer_acct ? t('Transfer: ') : '') + payee.name}
</span>
<span
style={{
borderRadius: 4,
padding: '3px 6px',
backgroundColor: theme.noticeBackground,
border: '1px solid ' + theme.noticeBackground,
color: theme.noticeTextDark,
fontSize: 12,
flexShrink: 0,
}}
>
<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>
<PayeeRuleCountLabel count={ruleCount} style={{ fontSize: 12 }} />
</span>
</SpaceBetween>
</ActionableGridListItem>
</Button>
);
});
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -7,14 +7,12 @@ 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';
@@ -29,21 +27,6 @@ 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') {
@@ -191,7 +174,7 @@ export function MobileRuleEditPage() {
rule={defaultRule}
onSave={handleSave}
onCancel={handleCancel}
onDelete={isEditing && !isLinkedToSchedule ? handleDelete : undefined}
onDelete={isEditing ? handleDelete : undefined}
style={{
paddingTop: 10,
flex: 1,

View File

@@ -5,8 +5,7 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send, listen } from 'loot-core/platform/client/fetch';
import * as undo from 'loot-core/platform/client/undo';
import { send } from 'loot-core/platform/client/fetch';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
import { type RuleEntity } from 'loot-core/types/models';
@@ -22,19 +21,17 @@ 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';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
const PAGE_SIZE = 50;
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({
@@ -79,12 +76,16 @@ export function MobileRulesPage() {
);
}, [visibleRules, filter, filterData, schedules]);
const loadRules = useCallback(async () => {
const loadRules = useCallback(async (append = false) => {
try {
setIsLoading(true);
const result = await send('rules-get');
const rules = result || [];
setAllRules(rules);
const newRules = result || [];
setAllRules(prevRules =>
append ? [...prevRules, ...newRules] : newRules,
);
setHasMoreRules(newRules.length === PAGE_SIZE);
} catch (error) {
console.error('Failed to load rules:', error);
setAllRules([]);
@@ -97,20 +98,6 @@ 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}`);
@@ -118,6 +105,12 @@ export function MobileRulesPage() {
[navigate],
);
const handleLoadMore = useCallback(() => {
if (!isLoading && hasMoreRules && !filter) {
loadRules(true);
}
}, [isLoading, hasMoreRules, filter, loadRules]);
const onSearchChange = useCallback(
(value: string) => {
setFilter(value);
@@ -125,47 +118,6 @@ 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={
@@ -201,7 +153,7 @@ export function MobileRulesPage() {
rules={filteredRules}
isLoading={isLoading}
onRulePress={handleRulePress}
onRuleDelete={handleRuleDelete}
onLoadMore={handleLoadMore}
/>
</Page>
);

View File

@@ -1,4 +1,4 @@
import { GridList } from 'react-aria-components';
import { type UIEvent } from 'react';
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;
onRuleDelete: (rule: RuleEntity) => void;
onLoadMore?: () => void;
};
export function RulesList({
rules,
isLoading,
onRulePress,
onRuleDelete,
onLoadMore,
}: RulesListProps) {
const { t } = useTranslation();
@@ -65,31 +65,32 @@ 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 }}>
<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>
<View
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
onScroll={handleScroll}
>
{rules.map(rule => (
<RulesListItem
key={rule.id}
rule={rule}
onPress={() => onRulePress(rule)}
/>
))}
{isLoading && (
<View
style={{
alignItems: 'center',
paddingTop: 20,
paddingVertical: 20,
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />

View File

@@ -1,31 +1,24 @@
import React from 'react';
import { type GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { 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';
type RulesListItemProps = {
onDelete: () => void;
} & WithRequired<GridListItemProps<RuleEntity>, 'value'>;
const ROW_HEIGHT = 60;
export function RulesListItem({
value: rule,
onDelete,
style,
...props
}: RulesListItemProps) {
type RulesListItemProps = {
rule: RuleEntity;
onPress: () => void;
};
export function RulesListItem({ rule, onPress }: RulesListItemProps) {
const { t } = useTranslation();
// Group actions by splitIndex to handle split transactions
@@ -33,172 +26,172 @@ export function RulesListItem({
const hasSplits = actionSplits.length > 1;
return (
<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}
<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}
>
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
{/* Column 1: PRE/POST pill */}
{/* Column 1: PRE/POST pill */}
<View
style={{
flexShrink: 0,
paddingTop: 2, // Slight top padding to align with text baseline
}}
>
<View
style={{
flexShrink: 0,
paddingTop: 2, // Slight top padding to align with text baseline
backgroundColor:
rule.stage === 'pre'
? theme.noticeBackgroundLight
: rule.stage === 'post'
? theme.warningBackground
: theme.pillBackgroundSelected,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 2,
paddingBottom: 2,
borderRadius: 3,
}}
>
<View
<span
style={{
backgroundColor:
fontSize: 11,
fontWeight: 500,
color:
rule.stage === 'pre'
? theme.noticeBackgroundLight
? theme.noticeTextLight
: rule.stage === 'post'
? theme.warningBackground
: theme.pillBackgroundSelected,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 2,
paddingBottom: 2,
borderRadius: 3,
? theme.warningText
: theme.pillTextSelected,
}}
>
<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>
{rule.stage === 'pre'
? t('PRE')
: rule.stage === 'post'
? t('POST')
: t('DEFAULT')}
</span>
</View>
</View>
{/* Column 2: IF and THEN blocks */}
{/* 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>
))}
</View>
{/* THEN actions block */}
<View
style={{
flex: 1,
flexDirection: 'column',
alignItems: 'flex-start',
gap: 4,
}}
>
{/* 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
<span
style={{
flexDirection: 'column',
alignItems: 'flex-start',
gap: 4,
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginBottom: 2,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginBottom: 2,
}}
>
{t('THEN')}
</span>
{t('THEN')}
</span>
{hasSplits
? actionSplits.map((split, i) => (
<View
key={i}
{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
style={{
width: '100%',
flexDirection: 'column',
alignItems: 'flex-start',
marginTop: i > 0 ? 4 : 0,
padding: '6px',
borderColor: theme.tableBorder,
borderWidth: '1px',
borderRadius: '5px',
fontSize: 11,
fontWeight: 500,
color: theme.pageTextLight,
marginBottom: 4,
}}
>
<span
{i ? t('Split {{num}}', { num: i }) : t('Apply to all')}
</span>
{split.actions.map((action, j) => (
<View
key={j}
style={{
fontSize: 11,
fontWeight: 500,
color: theme.pageTextLight,
marginBottom: 4,
marginBottom: j !== split.actions.length - 1 ? 2 : 0,
maxWidth: '100%',
}}
>
{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>
<ActionExpression {...action} />
</View>
))}
</View>
))
: rule.actions.map((action, index) => (
<View key={index} style={{ marginBottom: 2, maxWidth: '100%' }}>
<ActionExpression {...action} />
</View>
))}
</View>
</SpaceBetween>
</ActionableGridListItem>
</View>
</Button>
);
}

View File

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

View File

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

View File

@@ -1,139 +0,0 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { ButtonWithLoading } from '@actual-app/components/button';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { Error } from '@desktop-client/components/alerts';
import {
Modal,
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type BankSyncInitialiseProps = Extract<
ModalType,
{ name: 'bank-sync-init' }
>['options'];
export function BankSyncInitialiseModal({
providerSlug: _providerSlug,
providerDisplayName,
onSuccess,
}: BankSyncInitialiseProps) {
const { t } = useTranslation();
const [credentialsJson, setCredentialsJson] = useState('');
const [isValid, setIsValid] = useState(true);
const [error, setError] = useState('');
function onSubmit(close: () => void) {
if (!credentialsJson.trim()) {
setIsValid(false);
setError(t('Credentials JSON is required.'));
return;
}
// Validate JSON
try {
const parsedCredentials = JSON.parse(credentialsJson);
setIsValid(true);
onSuccess(parsedCredentials);
close();
} catch (err) {
setIsValid(false);
setError(t('Invalid JSON format. Please check your input.'));
}
}
return (
<Modal name="bank-sync-init" containerProps={{ style: { width: '40vw' } }}>
{({ state: { close } }) => (
<>
<ModalHeader
title={t('Set up {{provider}}', { provider: providerDisplayName })}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
<Trans>
Enter your API credentials as a JSON object. The plugin will
securely store these credentials.
</Trans>
</Text>
<Text style={{ fontSize: 12, color: 'var(--color-n8)' }}>
<Trans>Example:</Trans>
</Text>
<View
style={{
backgroundColor: 'var(--color-n1)',
padding: 10,
borderRadius: 4,
fontFamily: 'monospace',
fontSize: 12,
whiteSpace: 'pre',
}}
>
{/* eslint-disable actual/typography */}
{`{
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"itemIds": "id1,id2,id3"
}`}
{/* eslint-enable actual/typography */}
</View>
<FormField>
<FormLabel
title={t('Credentials (JSON):')}
htmlFor="credentials-field"
/>
<InitialFocus>
<textarea
id="credentials-field"
value={credentialsJson}
onChange={e => {
setCredentialsJson(e.target.value);
setIsValid(true);
}}
style={{
width: '100%',
minHeight: '120px',
padding: '8px',
fontFamily: 'monospace',
fontSize: '13px',
border: '1px solid var(--color-border)',
borderRadius: '4px',
backgroundColor: 'var(--color-n1)',
color: 'var(--color-n9)',
resize: 'vertical',
}}
// eslint-disable-next-line actual/typography
placeholder='{"clientId": "…", "clientSecret": "…"}'
/>
</InitialFocus>
</FormField>
{!isValid && <Error>{error}</Error>}
</View>
<ModalButtons>
<ButtonWithLoading
variant="primary"
onPress={() => {
onSubmit(close);
}}
>
<Trans>Save and continue</Trans>
</ButtonWithLoading>
</ModalButtons>
</>
)}
</Modal>
);
}

View File

@@ -25,11 +25,6 @@ import {
} from '@desktop-client/components/common/Modal';
import { useMultiuserEnabled } from '@desktop-client/components/ServerContext';
import { authorizeBank } from '@desktop-client/gocardless';
import {
useBankSyncProviders,
type BankSyncProvider,
} from '@desktop-client/hooks/useBankSyncProviders';
import { useBankSyncStatus } from '@desktop-client/hooks/useBankSyncStatus';
import { useGoCardlessStatus } from '@desktop-client/hooks/useGoCardlessStatus';
import { usePluggyAiStatus } from '@desktop-client/hooks/usePluggyAiStatus';
import { useSimpleFinStatus } from '@desktop-client/hooks/useSimpleFinStatus';
@@ -46,84 +41,6 @@ type CreateAccountModalProps = Extract<
{ name: 'add-account' }
>['options'];
// Separate component for each plugin provider button
function PluginProviderButton({
provider,
syncServerStatus,
onConnectProvider,
onResetCredentials,
}: {
provider: BankSyncProvider;
syncServerStatus: string;
onConnectProvider: (
provider: BankSyncProvider,
isConfigured: boolean,
) => void;
onResetCredentials: (provider: BankSyncProvider) => void;
}) {
const { t } = useTranslation();
const { configured: isConfigured, isLoading: statusLoading } =
useBankSyncStatus(provider.slug);
return (
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
isLoading={statusLoading}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={() => onConnectProvider(provider, isConfigured)}
>
{isConfigured
? t('Link account with {{provider}}', {
provider: provider.displayName,
})
: t('Set up {{provider}}', { provider: provider.displayName })}
</ButtonWithLoading>
{isConfigured && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('Bank sync provider options')}
style={{
padding: 8,
}}
>
<SvgDotsHorizontalTriple
style={{
width: 16,
height: 16,
color: 'currentColor',
}}
/>
</Button>
<Popover>
<Menu
onMenuSelect={itemId => {
if (itemId === 'reconfigure') {
onResetCredentials(provider);
}
}}
items={[
{
name: 'reconfigure',
text: t('Reconfigure {{provider}}', {
provider: provider.displayName,
}),
},
]}
/>
</Popover>
</DialogTrigger>
)}
</View>
);
}
export function CreateAccountModal({
upgradingAccountId,
}: CreateAccountModalProps) {
@@ -143,9 +60,6 @@ export function CreateAccountModal({
const { hasPermission } = useAuth();
const multiuserEnabled = useMultiuserEnabled();
// Plugin providers
const { providers: pluginProviders } = useBankSyncProviders();
const onConnectGoCardless = () => {
if (!isGoCardlessSetupComplete) {
onGoCardlessInit();
@@ -395,97 +309,6 @@ export function CreateAccountModal({
dispatch(pushModal({ modal: { name: 'add-local-account' } }));
};
// Plugin provider handlers
const onConnectPluginProvider = async (
provider: BankSyncProvider,
isConfigured: boolean,
credentials?: Record<string, string>,
) => {
if (!isConfigured) {
// Show initialization modal for setting up credentials
dispatch(
pushModal({
modal: {
name: 'bank-sync-init',
options: {
providerSlug: provider.slug,
providerDisplayName: provider.displayName,
onSuccess: async (credentials: Record<string, string>) => {
// The plugin system handles credentials internally
// After setup, try to connect again
onConnectPluginProvider(provider, true, credentials);
},
},
},
}),
);
return;
}
// If configured, fetch and display accounts
try {
const results = (await send('bank-sync-accounts', {
providerSlug: provider.slug,
credentials,
})) as any;
if (results.error_code) {
throw new Error(results.reason);
}
// The response should already be in the correct format for select-linked-accounts
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts: results.accounts || [],
syncSource: 'plugin' as const,
providerSlug: provider.slug,
upgradingAccountId,
},
},
}),
);
} catch (err) {
console.error('Error fetching plugin accounts:', err);
dispatch(
addNotification({
notification: {
type: 'error',
title: t('Error fetching accounts'),
message: (err as Error).message,
timeout: 5000,
},
}),
);
}
};
const onResetPluginProviderCredentials = async (
provider: BankSyncProvider,
) => {
// Open the configuration modal to allow user to enter new credentials
// This will override the old credentials when they complete the setup
//onConnectPluginProvider(provider, true);
dispatch(
pushModal({
modal: {
name: 'bank-sync-init',
options: {
providerSlug: provider.slug,
providerDisplayName: provider.displayName,
onSuccess: async (credentials: Record<string, string>) => {
// The plugin system handles credentials internally
// After setup, try to connect again
onConnectPluginProvider(provider, true, credentials);
},
},
},
}),
);
};
const { configuredGoCardless } = useGoCardlessStatus();
useEffect(() => {
setIsGoCardlessSetupComplete(configuredGoCardless);
@@ -756,42 +579,6 @@ export function CreateAccountModal({
</>
)}
{/* Plugin-based bank sync providers */}
{canSetSecrets && pluginProviders.length > 0 && (
<Text
style={{
lineHeight: '1.4em',
fontSize: 15,
textAlign: 'center',
marginTop: '18px',
}}
>
<Trans>
<strong>Plugin-based bank sync providers:</strong>
</Trans>
</Text>
)}
{canSetSecrets &&
pluginProviders.map(provider => (
<View
key={provider.slug}
style={{ gap: 10, marginTop: '18px' }}
>
<PluginProviderButton
provider={provider}
syncServerStatus={syncServerStatus}
onConnectProvider={onConnectPluginProvider}
onResetCredentials={onResetPluginProviderCredentials}
/>
{provider.description && (
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
{provider.description}
</Text>
)}
</View>
))}
{(!isGoCardlessSetupComplete ||
!isSimpleFinSetupComplete ||
!isPluggyAiSetupComplete) &&
@@ -802,9 +589,9 @@ export function CreateAccountModal({
secrets. Please contact an Admin to configure
</Trans>{' '}
{[
isGoCardlessSetupComplete ? '' : t('GoCardless'),
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
isPluggyAiSetupComplete ? '' : t('Pluggy.ai'),
isGoCardlessSetupComplete ? '' : 'GoCardless',
isSimpleFinSetupComplete ? '' : 'SimpleFin',
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
]
.filter(Boolean)
.join(' or ')}

View File

@@ -96,9 +96,7 @@ export function EnvelopeBudgetMonthMenuModal({
}}
>
<Notes
notes={
originalNotes?.length > 0 ? originalNotes : t('No notes')
}
notes={originalNotes?.length > 0 ? originalNotes : 'No notes'}
editable={false}
focused={false}
getStyle={() => ({

View File

@@ -30,8 +30,6 @@ type BackupTableProps = {
};
function BackupTable({ backups, onSelect }: BackupTableProps) {
const { t } = useTranslation();
return (
<View style={{ flex: 1, maxHeight: 200, overflow: 'auto' }}>
{backups.map((backup, idx) => (
@@ -43,7 +41,7 @@ function BackupTable({ backups, onSelect }: BackupTableProps) {
>
<Cell
width="flex"
value={backup.date ? backup.date : t('Revert to Latest')}
value={backup.date ? backup.date : 'Revert to Latest'}
valueStyle={{ paddingLeft: 20 }}
/>
</Row>

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