Compare commits
56 Commits
coderabbit
...
sync-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef14e1a79 | ||
|
|
1e5d5b9b78 | ||
|
|
33f6ae7f91 | ||
|
|
50fba76c47 | ||
|
|
744ae1625d | ||
|
|
9dda58b61d | ||
|
|
734bb86126 | ||
|
|
efb0d80aa4 | ||
|
|
605206d2f7 | ||
|
|
f7b40fca64 | ||
|
|
dc811552be | ||
|
|
295839ebbb | ||
|
|
99ca34458e | ||
|
|
90ac8d8520 | ||
|
|
52aeec2d59 | ||
|
|
0c280d60f6 | ||
|
|
148ca92584 | ||
|
|
90e848ebe8 | ||
|
|
b034d5039f | ||
|
|
5ac29473f2 | ||
|
|
3b0db2bed7 | ||
|
|
7a886810bc | ||
|
|
8bf0997275 | ||
|
|
2f965266ab | ||
|
|
499f24f7fd | ||
|
|
4c5be62f56 | ||
|
|
1446c7d93f | ||
|
|
ad9980307e | ||
|
|
d4ad31fb0c | ||
|
|
05355788e4 | ||
|
|
805e2b1807 | ||
|
|
e54dc0c1ca | ||
|
|
e1c2f0a181 | ||
|
|
cc2e329e8e | ||
|
|
71f849d1e1 | ||
|
|
0ea8bc1fb4 | ||
|
|
f0c7953c0b | ||
|
|
4cf5f9b183 | ||
|
|
80fd997540 | ||
|
|
da93ddf63b | ||
|
|
7846d2e787 | ||
|
|
ca6d80461a | ||
|
|
fa14cbb697 | ||
|
|
1210a74b4a | ||
|
|
534c1e6680 | ||
|
|
14d436712a | ||
|
|
e9f3925124 | ||
|
|
f28229be99 | ||
|
|
1fc922c672 | ||
|
|
c712217a7c | ||
|
|
3559b2df3a | ||
|
|
6365a8f4bb | ||
|
|
14426b64fd | ||
|
|
65790d4b9c | ||
|
|
9af4ba4d07 | ||
|
|
28caf8eaf9 |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -21,7 +21,7 @@ Naming Conventions
|
||||
|
||||
TypeScript Usage
|
||||
|
||||
- Use TypeScript for all code; prefer interfaces over types.
|
||||
- Use TypeScript for all code; prefer types over interfaces.
|
||||
- Avoid enums; use objects or maps instead.
|
||||
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
|
||||
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
|
||||
|
||||
6
.github/scripts/count-points.mjs
vendored
@@ -2,7 +2,7 @@ import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
const limit = pLimit(30);
|
||||
const limit = pLimit(50);
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
@@ -129,13 +129,13 @@ async function countContributorPoints(repo) {
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
octokit.search.issuesAndPullRequests,
|
||||
'GET /search/issues',
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data,
|
||||
response => response.data.filter(pr => pr.number),
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
|
||||
16
.github/workflows/build.yml
vendored
@@ -50,22 +50,6 @@ jobs:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
|
||||
plugins-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Plugins Core
|
||||
run: yarn workspace @actual-app/plugins-core build
|
||||
- name: Create package tgz
|
||||
run: cd packages/plugins-core && yarn pack && mv package.tgz actual-plugins-core.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-plugins-core
|
||||
path: packages/plugins-core/actual-plugins-core.tgz
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
6
.github/workflows/e2e-test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
|
||||
2
.github/workflows/electron-master.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
2
.github/workflows/electron-pr.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
2
.github/workflows/update-vrt.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
|
||||
2
.gitignore
vendored
@@ -26,6 +26,8 @@ packages/desktop-electron/build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
bundle.mobile.js
|
||||
|
||||
605
PLUGIN_ARCHITECTURE.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# Actual Budget Plugin Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Actual Budget's plugin system enables extending the sync-server with custom functionality through isolated, sandboxed processes. Plugins run as separate Node.js child processes that communicate with the sync-server via Inter-Process Communication (IPC).
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
A plugin is a standalone Node.js application that:
|
||||
|
||||
- **Runs as a child process** forked from the sync-server
|
||||
- **Uses Express.js** to define HTTP-like routes
|
||||
- **Communicates via IPC** instead of network sockets
|
||||
- **Has isolated dependencies** and runtime environment
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Plugin Manager** (`sync-server`) - Discovers, loads, and manages plugin lifecycle
|
||||
2. **Plugin Middleware** (`sync-server`) - Routes HTTP requests to appropriate plugins via IPC
|
||||
3. **Plugin Core Library** (`@actual-app/plugins-core-sync-server`) - Utilities for plugin authors
|
||||
4. **Plugin Process** - Your custom plugin code running as a child process
|
||||
|
||||
---
|
||||
|
||||
## Plugin Development
|
||||
|
||||
### 1. Project Setup
|
||||
|
||||
```bash
|
||||
# Create plugin directory
|
||||
mkdir my-plugin
|
||||
cd my-plugin
|
||||
|
||||
# Initialize npm project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install express @actual-app/plugins-core-sync-server
|
||||
npm install -D typescript @types/express @types/node
|
||||
```
|
||||
|
||||
### 2. Create Manifest
|
||||
|
||||
Every plugin needs a `manifest.ts` file that describes the plugin:
|
||||
|
||||
```typescript
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'My awesome plugin',
|
||||
entry: 'dist/index.js',
|
||||
author: 'Your Name',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/hello',
|
||||
methods: ['GET', 'POST'],
|
||||
auth: 'authenticated', // or 'anonymous'
|
||||
description: 'Hello endpoint',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
// Optional: for bank sync plugins
|
||||
enabled: true,
|
||||
displayName: 'My Bank Provider',
|
||||
description: 'Connect accounts via my provider',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
### 3. Create Plugin Code
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Essential: Parse JSON request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Essential: Enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Define your routes
|
||||
app.get('/hello', (req, res) => {
|
||||
res.json({ message: 'Hello from plugin!' });
|
||||
});
|
||||
|
||||
app.post('/save-config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
// Save secrets (encrypted & user-scoped)
|
||||
await saveSecret(req, 'apiKey', apiKey);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/config', async (req, res) => {
|
||||
// Retrieve secrets
|
||||
const result = await getSecret(req, 'apiKey');
|
||||
|
||||
res.json({ configured: !!result.value });
|
||||
});
|
||||
|
||||
// No need to call app.listen() - IPC handles communication
|
||||
console.log('My plugin loaded successfully');
|
||||
```
|
||||
|
||||
### 4. Build Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc && node build-manifest.js",
|
||||
"dev": "tsc --watch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The build process should:
|
||||
|
||||
1. Compile TypeScript to JavaScript
|
||||
2. Convert `manifest.ts` to `manifest.json`
|
||||
|
||||
---
|
||||
|
||||
## Plugin Loading Process
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Sync-Server Starts] --> B[Initialize PluginManager]
|
||||
B --> C[Scan plugins-api Directory]
|
||||
C --> D{Find Plugins}
|
||||
D -->|For each plugin| E[Read manifest.json]
|
||||
E --> F{Valid Manifest?}
|
||||
F -->|No| G[Skip Plugin]
|
||||
F -->|Yes| H[Fork Child Process]
|
||||
H --> I[Pass Environment Variables]
|
||||
I --> J[Plugin Process Starts]
|
||||
J --> K[attachPluginMiddleware Called]
|
||||
K --> L[Plugin Sends 'ready' Message]
|
||||
L --> M{Ready within timeout?}
|
||||
M -->|No| N[Reject Plugin]
|
||||
M -->|Yes| O[Mark Plugin as Online]
|
||||
O --> P[Register Routes]
|
||||
P --> Q[Plugin Available]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style Q fill:#d4edda
|
||||
style G fill:#f8d7da
|
||||
style N fill:#f8d7da
|
||||
```
|
||||
|
||||
### Loading Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginManager
|
||||
participant FS as File System
|
||||
participant PP as Plugin Process
|
||||
|
||||
SS->>PM: Initialize(pluginsDir)
|
||||
SS->>PM: loadPlugins()
|
||||
|
||||
PM->>FS: Read plugins-api directory
|
||||
FS-->>PM: List of plugin folders
|
||||
|
||||
loop For each plugin
|
||||
PM->>FS: Read manifest.json
|
||||
FS-->>PM: Manifest data
|
||||
|
||||
PM->>PM: Validate manifest
|
||||
|
||||
PM->>PP: fork(entryPoint)
|
||||
Note over PP: Plugin process starts
|
||||
|
||||
PP->>PP: Create Express app
|
||||
PP->>PP: Define routes
|
||||
PP->>PP: attachPluginMiddleware()
|
||||
|
||||
PP-->>PM: IPC: {type: 'ready'}
|
||||
|
||||
PM->>PM: Mark plugin as online
|
||||
PM->>PM: Register routes
|
||||
end
|
||||
|
||||
PM-->>SS: All plugins loaded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Communication Architecture
|
||||
|
||||
### HTTP Request Flow
|
||||
|
||||
When a client makes a request to a plugin endpoint:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginMiddleware
|
||||
participant MGR as PluginManager
|
||||
participant PP as Plugin Process
|
||||
|
||||
C->>SS: POST /plugins-api/my-plugin/hello
|
||||
SS->>PM: Route to plugin middleware
|
||||
|
||||
PM->>PM: Extract plugin slug & route
|
||||
PM->>PM: Check authentication
|
||||
PM->>PM: Verify route permissions
|
||||
|
||||
PM->>MGR: sendRequest(pluginSlug, requestData)
|
||||
|
||||
MGR->>PP: IPC: {type: 'request', method, path, body}
|
||||
|
||||
Note over PP: Plugin receives IPC message
|
||||
PP->>PP: Simulate HTTP request
|
||||
PP->>PP: Route to Express handler
|
||||
PP->>PP: Execute business logic
|
||||
|
||||
PP-->>MGR: IPC: {type: 'response', status, body}
|
||||
|
||||
MGR-->>PM: Response data
|
||||
PM-->>SS: Forward response
|
||||
SS-->>C: HTTP Response
|
||||
```
|
||||
|
||||
### IPC Message Types
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Sync-Server → Plugin"
|
||||
A[request<br/>HTTP request data]
|
||||
B[secret-response<br/>Secret value response]
|
||||
end
|
||||
|
||||
subgraph "Plugin → Sync-Server"
|
||||
C[ready<br/>Plugin initialized]
|
||||
D[response<br/>HTTP response data]
|
||||
E[secret-get<br/>Request secret]
|
||||
F[secret-set<br/>Save secret]
|
||||
G[error<br/>Error occurred]
|
||||
end
|
||||
|
||||
style A fill:#fff3cd
|
||||
style B fill:#fff3cd
|
||||
style C fill:#d4edda
|
||||
style D fill:#d4edda
|
||||
style E fill:#d1ecf1
|
||||
style F fill:#d1ecf1
|
||||
style G fill:#f8d7da
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
Plugins can store encrypted, user-scoped secrets (API keys, tokens, etc.):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PH as Plugin Handler
|
||||
participant PC as Plugin Core
|
||||
participant PP as Plugin Process (IPC)
|
||||
participant PM as PluginManager
|
||||
participant SS as Secrets Store
|
||||
|
||||
Note over PH: User saves API key
|
||||
|
||||
PH->>PC: saveSecret(req, 'apiKey', 'abc123')
|
||||
PC->>PC: Namespace: 'my-plugin_apiKey'
|
||||
PC->>PP: process.send({type: 'secret-set'})
|
||||
|
||||
PP-->>PM: IPC: secret-set message
|
||||
PM->>SS: Store secret (encrypted)
|
||||
SS-->>PM: Success
|
||||
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {success: true}
|
||||
|
||||
Note over PH: Later: retrieve secret
|
||||
|
||||
PH->>PC: getSecret(req, 'apiKey')
|
||||
PC->>PP: process.send({type: 'secret-get'})
|
||||
PP-->>PM: IPC: secret-get message
|
||||
PM->>SS: Retrieve secret
|
||||
SS-->>PM: Decrypted value
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {value: 'abc123'}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **User-scoped**: Each user has their own secrets
|
||||
- **Encrypted**: Stored securely in the database
|
||||
- **Namespaced**: Automatically prefixed with plugin slug
|
||||
- **Async**: Uses IPC promises for retrieval
|
||||
|
||||
---
|
||||
|
||||
## Plugin Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["Client (Browser/App)"]
|
||||
UI[User Interface]
|
||||
end
|
||||
|
||||
subgraph SyncServer["Sync-Server Process"]
|
||||
HTTP[HTTP Server]
|
||||
AUTH[Authentication]
|
||||
API[API Routes]
|
||||
PMW[Plugin Middleware]
|
||||
MGR[Plugin Manager]
|
||||
SEC[Secrets Store]
|
||||
end
|
||||
|
||||
subgraph Plugin1["Plugin Process 1"]
|
||||
P1APP[Express App]
|
||||
P1MW[Plugin Middleware]
|
||||
P1ROUTES[Route Handlers]
|
||||
P1LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
subgraph Plugin2["Plugin Process 2"]
|
||||
P2APP[Express App]
|
||||
P2MW[Plugin Middleware]
|
||||
P2ROUTES[Route Handlers]
|
||||
P2LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
UI -->|HTTP Request| HTTP
|
||||
HTTP --> AUTH
|
||||
AUTH --> API
|
||||
API --> PMW
|
||||
PMW -->|Route| MGR
|
||||
|
||||
MGR <-->|IPC<br/>Messages| P1MW
|
||||
MGR <-->|IPC<br/>Messages| P2MW
|
||||
|
||||
P1MW --> P1APP
|
||||
P1APP --> P1ROUTES
|
||||
P1ROUTES --> P1LOGIC
|
||||
|
||||
P2MW --> P2APP
|
||||
P2APP --> P2ROUTES
|
||||
P2ROUTES --> P2LOGIC
|
||||
|
||||
P1LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
P2LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
MGR <-.-> SEC
|
||||
|
||||
style Client fill:#e1f5ff
|
||||
style SyncServer fill:#fff3cd
|
||||
style Plugin1 fill:#d4edda
|
||||
style Plugin2 fill:#d4edda
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bank Sync Plugins
|
||||
|
||||
Bank sync plugins follow a specific contract to integrate with Actual's account linking:
|
||||
|
||||
### Required Endpoints
|
||||
|
||||
1. **`/status`** - Check if plugin is configured
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": { "configured": true }
|
||||
}
|
||||
```
|
||||
|
||||
2. **`/accounts`** - Fetch available accounts
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "ext-123",
|
||||
"name": "Checking",
|
||||
"institution": "My Bank",
|
||||
"balance": 1000,
|
||||
"mask": "1234",
|
||||
"official_name": "Primary Checking",
|
||||
"orgDomain": "mybank.com",
|
||||
"orgId": "bank-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **`/transactions`** - Fetch transactions
|
||||
|
||||
```json
|
||||
Request: {
|
||||
"accountId": "ext-123",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"transactions": {
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
|
||||
```typescript
|
||||
app.post('/endpoint', async (req, res) => {
|
||||
try {
|
||||
const result = await doSomething();
|
||||
res.json({ status: 'ok', data: result });
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Input Validation
|
||||
|
||||
```typescript
|
||||
app.post('/config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return res.json({
|
||||
status: 'error',
|
||||
error: 'apiKey is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Process...
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Logging
|
||||
|
||||
```typescript
|
||||
// Plugin stdout/stderr is visible in sync-server logs
|
||||
console.log('[MY-PLUGIN] Processing request...');
|
||||
console.error('[MY-PLUGIN] Error occurred:', error);
|
||||
```
|
||||
|
||||
### 4. Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[MY-PLUGIN] Shutting down...');
|
||||
// Cleanup resources
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
sync-server/
|
||||
└── user-files/
|
||||
└── plugins-api/
|
||||
└── my-plugin/
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Build the plugin** (as ZIP or folder)
|
||||
2. **Place in plugins-api directory**
|
||||
3. **Restart sync-server** (auto-loads on startup)
|
||||
|
||||
### ZIP Format (Recommended)
|
||||
|
||||
```
|
||||
my-plugin.zip
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
The plugin manager automatically extracts ZIPs to a temporary directory.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
- Check `manifest.json` exists and is valid JSON
|
||||
- Verify `entry` field points to correct file
|
||||
- Check sync-server logs for error messages
|
||||
|
||||
### IPC Communication Failures
|
||||
|
||||
- Ensure `attachPluginMiddleware(app)` is called
|
||||
- Verify plugin sends `ready` message within 10s timeout
|
||||
- Check that `process.send` is available (forked process)
|
||||
|
||||
### Route Not Found
|
||||
|
||||
- Verify route is defined in `manifest.json`
|
||||
- Check authentication requirements match
|
||||
- Ensure route path matches exactly (case-sensitive)
|
||||
|
||||
### Secrets Not Persisting
|
||||
|
||||
- Confirm user is authenticated
|
||||
- Check `pluginSlug` is passed in request context
|
||||
- Verify secrets store is properly initialized
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Bank Sync Plugin
|
||||
|
||||
See the [Pluggy.ai plugin](packages/bank-sync-plugin-pluggy.ai/) for a full working example that demonstrates:
|
||||
|
||||
- Authentication and configuration
|
||||
- Account fetching with proper typing
|
||||
- Transaction synchronization
|
||||
- Secret management
|
||||
- Error handling
|
||||
- TypeScript usage
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### `attachPluginMiddleware(app: Express)`
|
||||
|
||||
Enables IPC communication for the plugin. Must be called before defining routes.
|
||||
|
||||
### `saveSecret(req: Request, key: string, value: string)`
|
||||
|
||||
Saves an encrypted, user-scoped secret.
|
||||
|
||||
### `getSecret(req: Request, key: string)`
|
||||
|
||||
Retrieves a secret by key.
|
||||
|
||||
### `saveSecrets(req: Request, secrets: Record<string, string>)`
|
||||
|
||||
Saves multiple secrets at once.
|
||||
|
||||
### `getSecrets(req: Request, keys: string[])`
|
||||
|
||||
Retrieves multiple secrets at once.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Process Isolation** - Each plugin runs in its own process
|
||||
2. **Route Authentication** - Manifest declares auth requirements
|
||||
3. **Secret Encryption** - All secrets encrypted at rest
|
||||
4. **User Scoping** - Secrets isolated per user
|
||||
5. **Namespace Isolation** - Secrets auto-prefixed with plugin slug
|
||||
6. **No Direct DB Access** - Plugins can't access database directly
|
||||
7. **Controlled IPC** - Only specific message types allowed
|
||||
@@ -16,6 +16,7 @@ packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
|
||||
echo "E2E_START_URL: $E2E_START_URL"
|
||||
echo "VRT_ARGS: $VRT_ARGS"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
@@ -74,32 +74,30 @@ const confusingBrowserGlobals = [
|
||||
export default pluginTypescript.config(
|
||||
{
|
||||
ignores: [
|
||||
// Global ignore patterns
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.zip',
|
||||
// Specific ignore patterns
|
||||
'packages/api/app/bundle.api.js',
|
||||
'packages/api/app/stats.json',
|
||||
'packages/api/dist',
|
||||
'packages/api/@types',
|
||||
'packages/api/migrations',
|
||||
'packages/crdt/dist',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'packages/desktop-client/bundle.browser.js',
|
||||
'packages/desktop-client/build/',
|
||||
'packages/desktop-client/service-worker/*',
|
||||
'packages/desktop-client/build-electron/',
|
||||
'packages/desktop-client/build-stats/',
|
||||
'packages/desktop-client/public/kcab/',
|
||||
'packages/desktop-client/public/data/',
|
||||
'packages/desktop-client/**/node_modules/*',
|
||||
'packages/desktop-client/node_modules/',
|
||||
'packages/desktop-client/test-results/',
|
||||
'packages/desktop-client/playwright-report/',
|
||||
'packages/desktop-electron/client-build/',
|
||||
'packages/desktop-electron/build/',
|
||||
'packages/desktop-electron/dist/',
|
||||
'packages/loot-core/**/node_modules/*',
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
'packages/plugins-core/build/',
|
||||
'packages/plugins-core/node_modules/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
],
|
||||
@@ -765,6 +763,18 @@ export default pluginTypescript.config(
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/manifest.ts'],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'eslint.config.mjs',
|
||||
|
||||
33
package.json
@@ -23,17 +23,20 @@
|
||||
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
|
||||
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
@@ -44,7 +47,7 @@
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "prettier --check . && eslint . --max-warnings 0",
|
||||
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
|
||||
@@ -55,32 +58,32 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/node": "^22.17.0",
|
||||
"@types/node": "^22.18.8",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript-eslint/parser": "^8.42.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.3.5",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^6.0.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"globals": "^15.15.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.2",
|
||||
"lint-staged": "^16.2.3",
|
||||
"minimatch": "^10.0.3",
|
||||
"node-jq": "^6.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"p-limit": "^6.2.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier": "^3.6.2",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "25.9.0",
|
||||
"version": "25.10.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
@@ -24,14 +24,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function amountToInteger(n) {
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
export function integerToAmount(n) {
|
||||
return parseFloat((n / 100).toFixed(2));
|
||||
}
|
||||
1
packages/api/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated build artifacts
|
||||
manifest.json
|
||||
*.zip
|
||||
|
||||
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
Normal file
@@ -0,0 +1,459 @@
|
||||
import { attachPluginMiddleware, saveSecret, getSecret, BankSyncErrorCode, } from '@actual-app/plugins-core-sync-server';
|
||||
import express from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
// Create Express app
|
||||
const app = express();
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
// Pluggy client singleton
|
||||
let pluggyClient = null;
|
||||
async function getPluggyClient(req) {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
return pluggyClient;
|
||||
}
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req, res) => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray;
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id) => id.trim());
|
||||
}
|
||||
else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
}
|
||||
else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id) => id.trim());
|
||||
}
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts = [];
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
account.itemData = item;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`, error);
|
||||
}
|
||||
}
|
||||
accounts = accounts.concat(partial.results);
|
||||
}
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account) => {
|
||||
const institution = account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
const connectorId = account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain: account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post('/transactions', async (req, res) => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
let startingBalance = parseInt(Math.round(account.balance * 100).toString());
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt));
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
const all = [];
|
||||
const booked = [];
|
||||
const pending = [];
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans;
|
||||
const newTrans = {};
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
const transactionDate = new Date(transRecord.date);
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
transRecord.amountInAccountCurrency * -1;
|
||||
}
|
||||
transRecord.amount = transRecord.amount * -1;
|
||||
}
|
||||
let amountInCurrency = transRecord.amountInAccountCurrency ??
|
||||
transRecord.amount;
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
delete transRecord.amount;
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
}
|
||||
else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
const sortFunction = (a, b) => {
|
||||
const aRec = a;
|
||||
const bRec = b;
|
||||
return bRec.sortOrder - aRec.sortOrder;
|
||||
};
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Helper functions
|
||||
async function getTransactions(client, accountId, startDate) {
|
||||
let transactions = [];
|
||||
let result = await getTransactionsByAccountId(client, accountId, startDate, 500, 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(client, accountId, startDate, 500, currentPage + 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
async function getTransactionsByAccountId(client, accountId, startDate, pageSize, page) {
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map((t) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}));
|
||||
transactions.results =
|
||||
mappedResults;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
function getDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
function flattenObject(obj, prefix = '') {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenObject(value, newKey));
|
||||
}
|
||||
else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getPayeeName(trans) {
|
||||
const merchant = trans.merchant;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
const paymentData = trans.paymentData;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver;
|
||||
const docNum = receiverData.documentNumber;
|
||||
return receiverData.name || docNum?.value || '';
|
||||
}
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer;
|
||||
const docNum = payerData.documentNumber;
|
||||
return payerData.name || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
40
packages/bank-sync-plugin-pluggy.ai/dist/manifest.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
export const manifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
export default manifest;
|
||||
45
packages/bank-sync-plugin-pluggy.ai/manifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "pluggy-bank-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank synchronization plugin for Actual Budget",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Check Pluggy.ai configuration status"
|
||||
},
|
||||
{
|
||||
"path": "/accounts",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch accounts from Pluggy.ai"
|
||||
},
|
||||
{
|
||||
"path": "/transactions",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch transactions from Pluggy.ai"
|
||||
}
|
||||
],
|
||||
"bankSync": {
|
||||
"enabled": true,
|
||||
"displayName": "Pluggy.ai",
|
||||
"description": "Connect your bank accounts via Pluggy.ai",
|
||||
"requiresAuth": true,
|
||||
"endpoints": {
|
||||
"status": "/status",
|
||||
"accounts": "/accounts",
|
||||
"transactions": "/transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/bank-sync-plugin-pluggy.ai/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@actual-app/bank-sync-plugin-pluggy.ai",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank sync plugin for Actual Budget",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
|
||||
"build:compile": "tsc",
|
||||
"build:bundle": "node scripts/build-bundle.cjs",
|
||||
"build:manifest": "node scripts/build-manifest.cjs",
|
||||
"build:zip": "node scripts/build-zip.cjs",
|
||||
"deploy": "npm run build && npm run install:plugin",
|
||||
"install:plugin": "node scripts/install-plugin.cjs",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"bank-sync",
|
||||
"pluggy",
|
||||
"pluggyai"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"archiver": "^7.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/plugins-core-sync-server": "workspace:*",
|
||||
"express": "^4.18.0",
|
||||
"pluggy-sdk": "^0.77.0"
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-pluggy.ai/scripts/build-bundle.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to bundle the plugin with all dependencies
|
||||
* Uses esbuild to create a single self-contained JavaScript file
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const { join } = require('path');
|
||||
|
||||
async function bundle() {
|
||||
try {
|
||||
console.log('Bundling plugin with dependencies...');
|
||||
|
||||
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
|
||||
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: [],
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
console.log('Bundle created successfully');
|
||||
console.log(`Output: dist/bundle.js`);
|
||||
} catch (error) {
|
||||
console.error('Failed to bundle:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bundle();
|
||||
51
packages/bank-sync-plugin-pluggy.ai/scripts/build-manifest.cjs
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to convert TypeScript manifest to JSON
|
||||
* This script imports the manifest.ts file and writes it as JSON to manifest.json
|
||||
*/
|
||||
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Import the manifest from the built TypeScript file
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
async function importManifest() {
|
||||
// First try to import from the compiled JavaScript
|
||||
try {
|
||||
const manifestModule = await import('../dist/manifest.js');
|
||||
return manifestModule.manifest;
|
||||
} catch (error) {
|
||||
console.error('Could not import compiled manifest:', error.message);
|
||||
console.log(
|
||||
'Make sure TypeScript is compiled first. Run: npm run build:compile',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest() {
|
||||
try {
|
||||
console.log('Building manifest.json...');
|
||||
|
||||
// Import the manifest from the compiled TypeScript
|
||||
const manifest = await importManifest();
|
||||
|
||||
// Convert to JSON with pretty formatting
|
||||
const jsonContent = JSON.stringify(manifest, null, 2);
|
||||
|
||||
// Write to manifest.json in the root directory
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
writeFileSync(manifestPath, jsonContent + '\n');
|
||||
|
||||
console.log('manifest.json created successfully');
|
||||
console.log(`Package: ${manifest.name}@${manifest.version}`);
|
||||
console.log(`Description: ${manifest.description}`);
|
||||
console.log(`Entry point: ${manifest.entry}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
89
packages/bank-sync-plugin-pluggy.ai/scripts/build-zip.cjs
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to create a plugin distribution zip file
|
||||
* Creates: {packageName}.{version}.zip containing dist/index.js and manifest.json
|
||||
*/
|
||||
|
||||
const { createWriteStream, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// Import package.json to get name and version
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
function importPackageJson() {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
return packageJson;
|
||||
} catch (error) {
|
||||
console.error('Could not import package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZip() {
|
||||
try {
|
||||
console.log('Creating plugin distribution zip...');
|
||||
|
||||
// Get package info
|
||||
const packageJson = importPackageJson();
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
|
||||
// Create zip filename
|
||||
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
|
||||
const zipPath = join(__dirname, '..', zipFilename);
|
||||
|
||||
console.log(`Creating ${zipFilename}`);
|
||||
|
||||
// Check if required files exist
|
||||
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.error('dist/bundle.js not found. Run: npm run build:bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error('manifest.json not found. Run: npm run build:manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create zip file
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }, // Maximum compression
|
||||
});
|
||||
|
||||
// Handle archive events
|
||||
archive.on('error', err => {
|
||||
console.error('Archive error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
archive.on('end', () => {
|
||||
const stats = archive.pointer();
|
||||
console.log(`${zipFilename} created successfully`);
|
||||
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
|
||||
console.log(
|
||||
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
|
||||
);
|
||||
});
|
||||
|
||||
// Pipe archive to file
|
||||
archive.pipe(output);
|
||||
|
||||
// Add files to archive
|
||||
archive.file(bundlePath, { name: 'index.js' });
|
||||
archive.file(manifestPath, { name: 'manifest.json' });
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Failed to create zip:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createZip();
|
||||
70
packages/bank-sync-plugin-pluggy.ai/scripts/install-plugin.cjs
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
const pluginName = packageName.replace('@', '').replace('/', '-');
|
||||
const zipFileName = `${pluginName}.${version}.zip`;
|
||||
|
||||
// Source: built zip in package root (not in dist/)
|
||||
const sourceZip = path.join(__dirname, '..', zipFileName);
|
||||
|
||||
// Target: sync-server plugins directory
|
||||
// Go up to monorepo root, then to sync-server
|
||||
const targetDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'sync-server',
|
||||
'server-files',
|
||||
'plugins',
|
||||
);
|
||||
const targetZip = path.join(targetDir, zipFileName);
|
||||
|
||||
console.log('📦 Installing plugin to sync-server...');
|
||||
console.log(` Source: ${sourceZip}`);
|
||||
console.log(` Target: ${targetZip}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(sourceZip)) {
|
||||
console.error(`Error: ZIP file not found at ${sourceZip}`);
|
||||
console.error(' Run "npm run build" first to create the ZIP file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create target directory if it doesn't exist
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(`Creating plugins directory: ${targetDir}`);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove old versions of this plugin
|
||||
try {
|
||||
const files = fs.readdirSync(targetDir);
|
||||
const oldVersions = files.filter(
|
||||
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
|
||||
);
|
||||
|
||||
for (const oldFile of oldVersions) {
|
||||
const oldPath = path.join(targetDir, oldFile);
|
||||
console.log(` Removing old version: ${oldFile}`);
|
||||
fs.unlinkSync(oldPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` Warning: Could not clean old versions: ${err.message}`);
|
||||
}
|
||||
|
||||
// Copy the new ZIP
|
||||
try {
|
||||
fs.copyFileSync(sourceZip, targetZip);
|
||||
console.log(` Plugin installed successfully!`);
|
||||
console.log(` Location: ${targetZip}`);
|
||||
console.log('');
|
||||
console.log(' Restart your sync-server to load the plugin.');
|
||||
} catch (err) {
|
||||
console.error(` Error copying file: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
605
packages/bank-sync-plugin-pluggy.ai/src/index.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
BankSyncErrorCode,
|
||||
BankSyncError,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
|
||||
// Type definitions for Pluggy account structure
|
||||
type PluggyConnector = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
institutionUrl?: string;
|
||||
};
|
||||
|
||||
type PluggyItem = {
|
||||
connector?: PluggyConnector;
|
||||
};
|
||||
|
||||
type PluggyAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
number?: string;
|
||||
balance?: number;
|
||||
type?: string;
|
||||
itemId?: string;
|
||||
item?: PluggyItem;
|
||||
itemData?: PluggyItem;
|
||||
updatedAt?: string;
|
||||
currencyCode?: string;
|
||||
owner?: string;
|
||||
};
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Pluggy client singleton
|
||||
let pluggyClient: PluggyClient | null = null;
|
||||
|
||||
async function getPluggyClient(req: Request): Promise<PluggyClient> {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
return pluggyClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray: string[];
|
||||
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id: string) => id.trim());
|
||||
} else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
} else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
} else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error:
|
||||
'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id: string) => id.trim());
|
||||
}
|
||||
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts: PluggyAccount[] = [];
|
||||
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
(account as PluggyAccount).itemData = item;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accounts = accounts.concat(partial.results as PluggyAccount[]);
|
||||
}
|
||||
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account: PluggyAccount) => {
|
||||
const institution =
|
||||
account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
|
||||
const connectorId =
|
||||
account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain:
|
||||
account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post(
|
||||
'/transactions',
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
let startingBalance = parseInt(
|
||||
Math.round((account.balance as number) * 100).toString(),
|
||||
);
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt as string));
|
||||
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
|
||||
const all: unknown[] = [];
|
||||
const booked: unknown[] = [];
|
||||
const pending: unknown[] = [];
|
||||
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans as Record<string, unknown>;
|
||||
const newTrans: Record<string, unknown> = {};
|
||||
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
|
||||
const transactionDate = new Date(transRecord.date as string);
|
||||
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) * -1;
|
||||
}
|
||||
|
||||
transRecord.amount = (transRecord.amount as number) * -1;
|
||||
}
|
||||
|
||||
let amountInCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) ??
|
||||
(transRecord.amount as number);
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
|
||||
delete transRecord.amount;
|
||||
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
} else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
|
||||
const sortFunction = (a: unknown, b: unknown) => {
|
||||
const aRec = a as Record<string, unknown>;
|
||||
const bRec = b as Record<string, unknown>;
|
||||
return (bRec.sortOrder as number) - (aRec.sortOrder as number);
|
||||
};
|
||||
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
async function getTransactions(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
): Promise<unknown[]> {
|
||||
let transactions: unknown[] = [];
|
||||
let result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
currentPage + 1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
async function getTransactionsByAccountId(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
pageSize: number,
|
||||
page: number,
|
||||
): Promise<{ results: unknown[]; totalPages: number; page: number }> {
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}),
|
||||
);
|
||||
transactions.results =
|
||||
mappedResults as unknown as typeof transactions.results;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
function getDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function flattenObject(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = '',
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(
|
||||
result,
|
||||
flattenObject(value as Record<string, unknown>, newKey),
|
||||
);
|
||||
} else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPayeeName(trans: Record<string, unknown>): string {
|
||||
const merchant = trans.merchant as Record<string, string> | undefined;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
|
||||
const paymentData = trans.paymentData as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver as Record<string, unknown>;
|
||||
const docNum = receiverData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (receiverData.name as string) || docNum?.value || '';
|
||||
}
|
||||
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer as Record<string, unknown>;
|
||||
const docNum = payerData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (payerData.name as string) || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
43
packages/bank-sync-plugin-pluggy.ai/src/manifest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
28
packages/bank-sync-plugin-pluggy.ai/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/bank-sync-plugin-simplefin/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.zip
|
||||
*.log
|
||||
159
packages/bank-sync-plugin-simplefin/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# SimpleFIN Bank Sync Plugin
|
||||
|
||||
A bank synchronization plugin for Actual Budget that connects to financial institutions via SimpleFIN.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin enables Actual Budget to sync bank account data and transactions through the SimpleFIN API. SimpleFIN provides a unified interface to connect with various financial institutions.
|
||||
|
||||
## Features
|
||||
|
||||
- Account discovery and synchronization
|
||||
- Transaction import with proper categorization
|
||||
- Support for pending and posted transactions
|
||||
- Balance information retrieval
|
||||
- Error handling for connection issues
|
||||
|
||||
## Installation
|
||||
|
||||
1. Build the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Install the plugin to your sync-server:
|
||||
```bash
|
||||
npm run install:plugin
|
||||
```
|
||||
|
||||
3. Restart your sync-server to load the plugin.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin requires a SimpleFIN access token to authenticate with the SimpleFIN API.
|
||||
|
||||
### Getting a SimpleFIN Token
|
||||
|
||||
1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/auth/login)
|
||||
2. Sign up for an account
|
||||
3. Connect your financial institutions
|
||||
4. Generate an access token
|
||||
|
||||
### Plugin Setup
|
||||
|
||||
Once the plugin is installed, configure it in Actual Budget by providing your SimpleFIN token when prompted during the bank connection setup.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /status
|
||||
Check if the plugin is configured with valid credentials.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"configured": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /accounts
|
||||
Fetch available accounts from connected financial institutions.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"token": "your-simplefin-token" // optional, will be saved if provided
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "123456789",
|
||||
"name": "Checking Account",
|
||||
"institution": "Bank Name",
|
||||
"balance": 1234.56,
|
||||
"mask": "6789",
|
||||
"official_name": "Premium Checking",
|
||||
"orgDomain": "bank.com",
|
||||
"orgId": "BANK123"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /transactions
|
||||
Fetch transactions for specific accounts within a date range.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"accountId": "123456789",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"balances": [
|
||||
{
|
||||
"balanceAmount": {
|
||||
"amount": "1234.56",
|
||||
"currency": "USD"
|
||||
},
|
||||
"balanceType": "expected",
|
||||
"referenceDate": "2024-01-15"
|
||||
}
|
||||
],
|
||||
"startingBalance": 123456,
|
||||
"transactions": {
|
||||
"all": [...],
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The plugin provides detailed error messages for various failure scenarios:
|
||||
|
||||
- `INVALID_ACCESS_TOKEN`: Invalid or expired SimpleFIN token
|
||||
- `SERVER_DOWN`: Communication issues with SimpleFIN
|
||||
- `ACCOUNT_MISSING`: Specified account not found
|
||||
- `ACCOUNT_NEEDS_ATTENTION`: Account requires attention on SimpleFIN Bridge
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build # Full build (compile + bundle + manifest + zip)
|
||||
npm run build:compile # TypeScript compilation only
|
||||
npm run build:bundle # Bundle with dependencies
|
||||
npm run build:manifest # Generate manifest.json
|
||||
npm run build:zip # Create distribution zip
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The plugin integrates with Actual Budget's existing test infrastructure. Run tests from the monorepo root:
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
45
packages/bank-sync-plugin-simplefin/manifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "simplefin-bank-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "SimpleFIN bank synchronization plugin for Actual Budget",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Check SimpleFIN configuration status"
|
||||
},
|
||||
{
|
||||
"path": "/accounts",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch accounts from SimpleFIN"
|
||||
},
|
||||
{
|
||||
"path": "/transactions",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch transactions from SimpleFIN"
|
||||
}
|
||||
],
|
||||
"bankSync": {
|
||||
"enabled": true,
|
||||
"displayName": "SimpleFIN",
|
||||
"description": "Connect your bank accounts via SimpleFIN",
|
||||
"requiresAuth": true,
|
||||
"endpoints": {
|
||||
"status": "/status",
|
||||
"accounts": "/accounts",
|
||||
"transactions": "/transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-simplefin/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@actual-app/bank-sync-plugin-simplefin",
|
||||
"version": "0.0.1",
|
||||
"description": "SimpleFIN bank sync plugin for Actual Budget",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
|
||||
"build:compile": "tsc",
|
||||
"build:bundle": "node scripts/build-bundle.cjs",
|
||||
"build:manifest": "node scripts/build-manifest.cjs",
|
||||
"build:zip": "node scripts/build-zip.cjs",
|
||||
"deploy": "npm run build && npm run install:plugin",
|
||||
"install:plugin": "node scripts/install-plugin.cjs",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"bank-sync",
|
||||
"simplefin"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"archiver": "^7.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/plugins-core-sync-server": "workspace:*",
|
||||
"axios": "^1.6.0",
|
||||
"express": "^4.18.0"
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-simplefin/scripts/build-bundle.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to bundle the plugin with all dependencies
|
||||
* Uses esbuild to create a single self-contained JavaScript file
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const { join } = require('path');
|
||||
|
||||
async function bundle() {
|
||||
try {
|
||||
console.log('Bundling plugin with dependencies...');
|
||||
|
||||
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
|
||||
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: ['express', 'axios'],
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
console.log('Bundle created successfully');
|
||||
console.log(`Output: dist/bundle.js`);
|
||||
} catch (error) {
|
||||
console.error('Failed to bundle:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bundle();
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to convert TypeScript manifest to JSON
|
||||
* This script imports the manifest.ts file and writes it as JSON to manifest.json
|
||||
*/
|
||||
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Import the manifest from the built TypeScript file
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
async function importManifest() {
|
||||
// First try to import from the compiled JavaScript
|
||||
try {
|
||||
const manifestModule = await import('../dist/manifest.js');
|
||||
return manifestModule.manifest;
|
||||
} catch (error) {
|
||||
console.error('Could not import compiled manifest:', error.message);
|
||||
console.log(
|
||||
'Make sure TypeScript is compiled first. Run: npm run build:compile',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest() {
|
||||
try {
|
||||
console.log('Building manifest.json...');
|
||||
|
||||
// Import the manifest from the compiled TypeScript
|
||||
const manifest = await importManifest();
|
||||
|
||||
// Convert to JSON with pretty formatting
|
||||
const jsonContent = JSON.stringify(manifest, null, 2);
|
||||
|
||||
// Write to manifest.json in the root directory
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
writeFileSync(manifestPath, jsonContent + '\n');
|
||||
|
||||
console.log('manifest.json created successfully');
|
||||
console.log(`Package: ${manifest.name}@${manifest.version}`);
|
||||
console.log(`Description: ${manifest.description}`);
|
||||
console.log(`Entry point: ${manifest.entry}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
104
packages/bank-sync-plugin-simplefin/scripts/build-zip.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to create a plugin distribution zip file
|
||||
* Creates: {packageName}.{version}.zip containing dist/index.js, manifest.json, and package.json
|
||||
*/
|
||||
|
||||
const { createWriteStream, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// Import package.json to get name and version
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
function importPackageJson() {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
return packageJson;
|
||||
} catch (error) {
|
||||
console.error('Could not import package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZip() {
|
||||
try {
|
||||
console.log('Creating plugin distribution zip...');
|
||||
|
||||
// Get package info
|
||||
const packageJson = importPackageJson();
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
|
||||
// Create zip filename
|
||||
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
|
||||
const zipPath = join(__dirname, '..', zipFilename);
|
||||
|
||||
console.log(`Creating ${zipFilename}`);
|
||||
|
||||
// Check if required files exist
|
||||
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.error('dist/bundle.js not found. Run: npm run build:bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error('manifest.json not found. Run: npm run build:manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create zip file
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }, // Maximum compression
|
||||
});
|
||||
|
||||
// Handle archive events
|
||||
archive.on('error', err => {
|
||||
console.error('Archive error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
archive.on('end', () => {
|
||||
const stats = archive.pointer();
|
||||
console.log(`${zipFilename} created successfully`);
|
||||
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
|
||||
console.log(
|
||||
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
|
||||
);
|
||||
});
|
||||
|
||||
// Pipe archive to file
|
||||
archive.pipe(output);
|
||||
|
||||
// Create package.json for the plugin with runtime dependencies
|
||||
const pluginPackageJson = {
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
express: packageJson.dependencies.express,
|
||||
axios: packageJson.dependencies.axios,
|
||||
},
|
||||
};
|
||||
const pluginPackageJsonContent = JSON.stringify(
|
||||
pluginPackageJson,
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Add files to archive
|
||||
archive.file(bundlePath, { name: 'index.js' });
|
||||
archive.file(manifestPath, { name: 'manifest.json' });
|
||||
archive.append(pluginPackageJsonContent, { name: 'package.json' });
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Failed to create zip:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createZip();
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
const pluginName = packageName.replace('@', '').replace('/', '-');
|
||||
const zipFileName = `${pluginName}.${version}.zip`;
|
||||
|
||||
// Source: built zip in package root (not in dist/)
|
||||
const sourceZip = path.join(__dirname, '..', zipFileName);
|
||||
|
||||
// Target: sync-server plugins directory
|
||||
// Go up to monorepo root, then to sync-server
|
||||
const targetDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'sync-server',
|
||||
'server-files',
|
||||
'plugins',
|
||||
);
|
||||
const targetZip = path.join(targetDir, zipFileName);
|
||||
|
||||
console.log('📦 Installing plugin to sync-server...');
|
||||
console.log(` Source: ${sourceZip}`);
|
||||
console.log(` Target: ${targetZip}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(sourceZip)) {
|
||||
console.error(`Error: ZIP file not found at ${sourceZip}`);
|
||||
console.error(' Run "npm run build" first to create the ZIP file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create target directory if it doesn't exist
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(`Creating plugins directory: ${targetDir}`);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove old versions of this plugin
|
||||
try {
|
||||
const files = fs.readdirSync(targetDir);
|
||||
const oldVersions = files.filter(
|
||||
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
|
||||
);
|
||||
|
||||
for (const oldFile of oldVersions) {
|
||||
const oldPath = path.join(targetDir, oldFile);
|
||||
console.log(` Removing old version: ${oldFile}`);
|
||||
fs.unlinkSync(oldPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` Warning: Could not clean old versions: ${err.message}`);
|
||||
}
|
||||
|
||||
// Copy the new ZIP
|
||||
try {
|
||||
fs.copyFileSync(sourceZip, targetZip);
|
||||
console.log(` Plugin installed successfully!`);
|
||||
console.log(` Location: ${targetZip}`);
|
||||
console.log('');
|
||||
console.log(' Restart your sync-server to load the plugin.');
|
||||
} catch (err) {
|
||||
console.error(` Error copying file: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
562
packages/bank-sync-plugin-simplefin/src/index.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
BankSyncErrorCode,
|
||||
BankSyncError,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
|
||||
// Type definitions for SimpleFIN account structure
|
||||
type SimpleFINAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: string;
|
||||
currency: string;
|
||||
'balance-date': number;
|
||||
org: {
|
||||
name: string;
|
||||
domain?: string;
|
||||
};
|
||||
transactions: SimpleFINTransaction[];
|
||||
};
|
||||
|
||||
type SimpleFINTransaction = {
|
||||
id: string;
|
||||
payee: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
transacted_at?: number;
|
||||
posted?: number;
|
||||
pending?: boolean | number;
|
||||
};
|
||||
|
||||
type SimpleFINResponse = {
|
||||
accounts: SimpleFINAccount[];
|
||||
errors: string[];
|
||||
sferrors: string[];
|
||||
hasError: boolean;
|
||||
accountErrors?: Record<string, any[]>;
|
||||
};
|
||||
|
||||
type ParsedAccessKey = {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
/**
|
||||
* POST /status
|
||||
* Check if SimpleFIN is configured
|
||||
*/
|
||||
app.post('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const tokenResult = await getSecret(req, 'simplefin_token');
|
||||
const configured = tokenResult.value != null && tokenResult.value !== 'Forbidden';
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from SimpleFIN
|
||||
* Body: { token?: string }
|
||||
*
|
||||
* If token is provided, it will be saved as a secret
|
||||
*/
|
||||
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
// If token is provided in request, save it
|
||||
if (token) {
|
||||
await saveSecret(req, 'simplefin_token', token);
|
||||
}
|
||||
|
||||
let accessKey: string | null = null;
|
||||
|
||||
try {
|
||||
const tokenResult = await getSecret(req, 'simplefin_token');
|
||||
const storedToken = tokenResult.value;
|
||||
|
||||
if (storedToken == null || storedToken === 'Forbidden') {
|
||||
throw new Error('No token');
|
||||
} else {
|
||||
accessKey = await getAccessKey(storedToken);
|
||||
await saveSecret(req, 'simplefin_accessKey', accessKey);
|
||||
if (accessKey == null || accessKey === 'Forbidden') {
|
||||
throw new Error('No access key');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: 'INVALID_ACCESS_TOKEN',
|
||||
error_code: 'INVALID_ACCESS_TOKEN',
|
||||
status: 'rejected',
|
||||
reason:
|
||||
'Invalid SimpleFIN access token. Reset the token and re-link any broken accounts.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await getAccounts(accessKey, null, null, null, true);
|
||||
|
||||
// Transform SimpleFIN accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.accounts.map((account: SimpleFINAccount) => ({
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution: account.org.name,
|
||||
balance: parseFloat(account.balance.replace('.', '')) / 100,
|
||||
mask: account.id.substring(account.id.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain: account.org.domain || null,
|
||||
orgId: account.org.name,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[SIMPLEFIN ACCOUNTS] Error:', e);
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.SERVER_ERROR,
|
||||
error_code: BankSyncErrorCode.SERVER_ERROR,
|
||||
status: 'error',
|
||||
reason: 'There was an error communicating with SimpleFIN.',
|
||||
};
|
||||
|
||||
if (e instanceof Error) {
|
||||
const errorMessage = e.message.toLowerCase();
|
||||
|
||||
if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.reason = 'Invalid SimpleFIN access token. Please reconfigure your connection.';
|
||||
} else if (errorMessage.includes('401') || errorMessage.includes('unauthorized')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.reason = 'Unauthorized access to SimpleFIN. Please check your credentials.';
|
||||
} else if (errorMessage.includes('network') || errorMessage.includes('econnrefused') || errorMessage.includes('enotfound')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.reason = 'Network error communicating with SimpleFIN. Please check your connection.';
|
||||
}
|
||||
|
||||
errorResponse.details = { originalError: e.message };
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from SimpleFIN
|
||||
* Body: { accountId: string, startDate: string, token?: string }
|
||||
*/
|
||||
app.post('/transactions', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body || {};
|
||||
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const accessKeyResult = await getSecret(req, 'simplefin_accessKey');
|
||||
|
||||
if (accessKeyResult.value == null || accessKeyResult.value === 'Forbidden') {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: 'INVALID_ACCESS_TOKEN',
|
||||
error_code: 'INVALID_ACCESS_TOKEN',
|
||||
status: 'rejected',
|
||||
reason:
|
||||
'Invalid SimpleFIN access token. Reset the token and re-link any broken accounts.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(accountId) !== Array.isArray(startDate)) {
|
||||
console.log({ accountId, startDate });
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId and startDate must either both be arrays or both be strings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(accountId) && accountId.length !== startDate.length) {
|
||||
console.log({ accountId, startDate });
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId and startDate arrays must be the same length',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestStartDate = Array.isArray(startDate)
|
||||
? startDate.reduce((a, b) => (a < b ? a : b))
|
||||
: startDate;
|
||||
|
||||
let results: SimpleFINResponse;
|
||||
try {
|
||||
results = await getTransactions(
|
||||
accessKeyResult.value,
|
||||
Array.isArray(accountId) ? accountId : [accountId],
|
||||
new Date(earliestStartDate),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[SIMPLEFIN TRANSACTIONS] Error:', e);
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.SERVER_ERROR,
|
||||
error_code: BankSyncErrorCode.SERVER_ERROR,
|
||||
status: 'error',
|
||||
reason: 'There was an error communicating with SimpleFIN.',
|
||||
};
|
||||
|
||||
if (e instanceof Error) {
|
||||
const errorMessage = e.message.toLowerCase();
|
||||
|
||||
if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.reason = 'Invalid SimpleFIN access token. Please reconfigure your connection.';
|
||||
} else if (errorMessage.includes('401') || errorMessage.includes('unauthorized')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.reason = 'Unauthorized access to SimpleFIN. Please check your credentials.';
|
||||
} else if (errorMessage.includes('404') || errorMessage.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.reason = 'Account not found in SimpleFIN. Please check your account configuration.';
|
||||
} else if (errorMessage.includes('network') || errorMessage.includes('econnrefused') || errorMessage.includes('enotfound')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.reason = 'Network error communicating with SimpleFIN. Please check your connection.';
|
||||
}
|
||||
|
||||
errorResponse.details = { originalError: e.message };
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let response: any = {};
|
||||
if (Array.isArray(accountId)) {
|
||||
for (let i = 0; i < accountId.length; i++) {
|
||||
const id = accountId[i];
|
||||
response[id] = getAccountResponse(results, id, new Date(startDate[i]));
|
||||
}
|
||||
} else {
|
||||
response = getAccountResponse(results, accountId, new Date(startDate));
|
||||
}
|
||||
|
||||
if (results.hasError) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: !Array.isArray(accountId)
|
||||
? (results.accountErrors?.[accountId]?.[0] || results.errors[0])
|
||||
: {
|
||||
...response,
|
||||
errors: results.accountErrors || results.errors,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: response,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function logAccountError(results: SimpleFINResponse, accountId: string, data: any) {
|
||||
// For account-specific errors, we store them in the results object for later retrieval
|
||||
if (!results.accountErrors) {
|
||||
results.accountErrors = {};
|
||||
}
|
||||
const errors = results.accountErrors[accountId] || [];
|
||||
errors.push(data);
|
||||
results.accountErrors[accountId] = errors;
|
||||
results.hasError = true;
|
||||
}
|
||||
|
||||
function getAccountResponse(results: SimpleFINResponse, accountId: string, startDate: Date): any {
|
||||
const account = !results?.accounts ? undefined : results.accounts.find(a => a.id === accountId);
|
||||
if (!account) {
|
||||
console.log(
|
||||
`The account "${accountId}" was not found. Here were the accounts returned:`,
|
||||
);
|
||||
if (results?.accounts) {
|
||||
results.accounts.forEach(a => console.log(`${a.id} - ${a.org.name}`));
|
||||
}
|
||||
logAccountError(results, accountId, {
|
||||
error_type: 'ACCOUNT_MISSING',
|
||||
error_code: 'ACCOUNT_MISSING',
|
||||
reason: `The account "${accountId}" was not found. Try unlinking and relinking the account.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const needsAttention = results.sferrors.find(e =>
|
||||
e.startsWith(`Connection to ${account.org.name} may need attention`),
|
||||
);
|
||||
if (needsAttention) {
|
||||
logAccountError(results, accountId, {
|
||||
error_type: 'ACCOUNT_NEEDS_ATTENTION',
|
||||
error_code: 'ACCOUNT_NEEDS_ATTENTION',
|
||||
reason:
|
||||
'The account needs your attention at <a href="https://bridge.simplefin.org/auth/login">SimpleFIN</a>.',
|
||||
});
|
||||
}
|
||||
|
||||
const startingBalance = parseInt(account.balance.replace('.', ''));
|
||||
const date = getDate(new Date(account['balance-date'] * 1000));
|
||||
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
},
|
||||
balanceType: 'interimAvailable',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
|
||||
const all: any[] = [];
|
||||
const booked: any[] = [];
|
||||
const pending: any[] = [];
|
||||
|
||||
for (const trans of account.transactions) {
|
||||
const newTrans: any = {};
|
||||
|
||||
let dateToUse = 0;
|
||||
|
||||
if (trans.pending ?? trans.posted === 0) {
|
||||
newTrans.booked = false;
|
||||
dateToUse = trans.transacted_at || 0;
|
||||
} else {
|
||||
newTrans.booked = true;
|
||||
dateToUse = trans.posted || 0;
|
||||
}
|
||||
|
||||
const transactionDate = new Date(dateToUse * 1000);
|
||||
|
||||
if (transactionDate < startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrans.sortOrder = dateToUse;
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = trans.payee;
|
||||
newTrans.notes = trans.description;
|
||||
newTrans.transactionAmount = { amount: trans.amount, currency: 'USD' };
|
||||
newTrans.transactionId = trans.id;
|
||||
newTrans.valueDate = newTrans.bookingDate;
|
||||
|
||||
if (trans.transacted_at) {
|
||||
newTrans.transactedDate = getDate(new Date(trans.transacted_at * 1000));
|
||||
}
|
||||
|
||||
if (trans.posted) {
|
||||
newTrans.postedDate = getDate(new Date(trans.posted * 1000));
|
||||
}
|
||||
|
||||
if (newTrans.booked) {
|
||||
booked.push(newTrans);
|
||||
} else {
|
||||
pending.push(newTrans);
|
||||
}
|
||||
all.push(newTrans);
|
||||
}
|
||||
|
||||
const sortFunction = (a: any, b: any) => b.sortOrder - a.sortOrder;
|
||||
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
|
||||
return {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAccessKey(accessKey: string): ParsedAccessKey {
|
||||
if (!accessKey || !accessKey.match(/^.*\/\/.*:.*@.*$/)) {
|
||||
console.log(`Invalid SimpleFIN access key: ${accessKey}`);
|
||||
throw new Error(`Invalid access key`);
|
||||
}
|
||||
const [scheme, rest] = accessKey.split('//');
|
||||
const [auth, restAfterAuth] = rest.split('@');
|
||||
const [username, password] = auth.split(':');
|
||||
const baseUrl = `${scheme}//${restAfterAuth}`;
|
||||
return {
|
||||
baseUrl,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAccessKey(base64Token: string): Promise<string> {
|
||||
const token = Buffer.from(base64Token, 'base64').toString();
|
||||
|
||||
const response = await axios.post(token, undefined, {
|
||||
headers: { 'Content-Length': 0 },
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getTransactions(
|
||||
accessKey: string,
|
||||
accounts: string[],
|
||||
startDate: Date,
|
||||
endDate?: Date,
|
||||
): Promise<SimpleFINResponse> {
|
||||
const now = new Date();
|
||||
startDate = startDate || new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
endDate = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
console.log(`${getDate(startDate)} - ${getDate(endDate)}`);
|
||||
return await getAccounts(accessKey, accounts, startDate, endDate);
|
||||
}
|
||||
|
||||
function getDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function normalizeDate(date: Date): number {
|
||||
return (date.valueOf() - date.getTimezoneOffset() * 60 * 1000) / 1000;
|
||||
}
|
||||
|
||||
async function getAccounts(
|
||||
accessKey: string,
|
||||
accounts?: string[] | null,
|
||||
startDate?: Date | null,
|
||||
endDate?: Date | null,
|
||||
noTransactions = false,
|
||||
): Promise<SimpleFINResponse> {
|
||||
const sfin = parseAccessKey(accessKey);
|
||||
|
||||
const headers = {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${sfin.username}:${sfin.password}`,
|
||||
).toString('base64')}`,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (!noTransactions) {
|
||||
if (startDate) {
|
||||
params.append('start-date', normalizeDate(startDate).toString());
|
||||
}
|
||||
if (endDate) {
|
||||
params.append('end-date', normalizeDate(endDate).toString());
|
||||
}
|
||||
params.append('pending', '1');
|
||||
} else {
|
||||
params.append('balances-only', '1');
|
||||
}
|
||||
|
||||
if (accounts) {
|
||||
for (const id of accounts) {
|
||||
params.append('account', id);
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(`${sfin.baseUrl}/accounts`);
|
||||
url.search = params.toString();
|
||||
|
||||
const response = await axios.get(url.toString(), {
|
||||
headers,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new Error('Forbidden');
|
||||
}
|
||||
|
||||
// axios automatically parses JSON, so response.data is already an object
|
||||
const results: SimpleFINResponse = response.data as SimpleFINResponse;
|
||||
results.sferrors = results.errors;
|
||||
results.hasError = false;
|
||||
results.errors = [];
|
||||
results.accountErrors = {};
|
||||
return results;
|
||||
}
|
||||
|
||||
console.log('SimpleFIN Bank Sync Plugin loaded');
|
||||
43
packages/bank-sync-plugin-simplefin/src/manifest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'simplefin-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'SimpleFIN bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check SimpleFIN configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from SimpleFIN',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from SimpleFIN',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'SimpleFIN',
|
||||
description: 'Connect your bank accounts via SimpleFIN',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
28
packages/bank-sync-plugin-simplefin/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -8,14 +8,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"react-aria-components": "^1.8.0",
|
||||
"react-aria-components": "^1.13.0",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"@types/react": "^19.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"exports": {
|
||||
@@ -49,8 +49,7 @@
|
||||
"./toggle": "./src/Toggle.tsx",
|
||||
"./tooltip": "./src/Tooltip.tsx",
|
||||
"./view": "./src/View.tsx",
|
||||
"./color-picker": "./src/ColorPicker.tsx",
|
||||
"./props/*": "./src/props/*.ts"
|
||||
"./color-picker": "./src/ColorPicker.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { CSSProperties } from '../styles';
|
||||
|
||||
export type BasicModalProps = {
|
||||
isLoading?: boolean;
|
||||
noAnimation?: boolean;
|
||||
style?: CSSProperties;
|
||||
onClose?: () => void;
|
||||
containerProps?: {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
};
|
||||
@@ -154,4 +154,10 @@ export const styles: Record<string, any> = {
|
||||
borderRadius: 4,
|
||||
padding: '3px 5px',
|
||||
},
|
||||
mobileListItem: {
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
backgroundColor: theme.tableBackground,
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "^3.15.12",
|
||||
"protoc-gen-js": "^3.21.4-4",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/desktop-client/.gitignore
vendored
@@ -14,6 +14,9 @@ build-electron
|
||||
build-stats
|
||||
stats.json
|
||||
|
||||
# generated service worker
|
||||
service-worker/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -65,10 +65,10 @@ Run manually:
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
|
||||
|
||||
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
|
||||
|
||||
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
|
||||
# Use the ip and port noted earlier
|
||||
|
||||
@@ -9,6 +9,7 @@ rm -fr build
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
|
||||
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH=`ls "$ROOT"/../service-worker/plugin-sw.*.js | sed 's/.*plugin-sw\.\(.*\)\.js/\1/'`
|
||||
|
||||
yarn build
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@ cd "$ROOT/.."
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export PORT=3001
|
||||
export REACT_APP_BACKEND_WORKER_HASH="dev"
|
||||
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH="dev"
|
||||
|
||||
yarn start
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 78 KiB |
@@ -40,17 +40,14 @@ export class MobileRulesPage {
|
||||
* Get the nth rule item (0-based index)
|
||||
*/
|
||||
getNthRule(index: number) {
|
||||
return this.page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: /IF|THEN/ })
|
||||
.nth(index);
|
||||
return this.getAllRules().nth(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all visible rule items
|
||||
*/
|
||||
getAllRules() {
|
||||
return this.page.getByRole('button').filter({ hasText: /IF|THEN/ });
|
||||
return this.page.getByRole('grid', { name: 'Rules' }).getByRole('row');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +109,7 @@ export class MobileRulesPage {
|
||||
*/
|
||||
async getRuleStage(index: number) {
|
||||
const rule = this.getNthRule(index);
|
||||
const stageBadge = rule.locator('span').first();
|
||||
const stageBadge = rule.getByTestId('rule-stage-badge');
|
||||
return await stageBadge.textContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,15 @@ export class RulesPage {
|
||||
|
||||
if (op && !fieldFirst) {
|
||||
await row.getByTestId('op-select').getByRole('button').first().click();
|
||||
await this.page.getByRole('button', { name: op, exact: true }).click();
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
if (field) {
|
||||
@@ -133,12 +141,26 @@ export class RulesPage {
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('button', { name: field, exact: true })
|
||||
.click();
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: field, exact: true })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
if (op && fieldFirst) {
|
||||
await row.getByTestId('op-select').getByRole('button').first().click();
|
||||
await this.page.getByRole('button', { name: op, exact: true }).click();
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
if (value) {
|
||||
|
||||
@@ -28,7 +28,10 @@ export class SchedulesPage {
|
||||
|
||||
await this._fillScheduleFields(data);
|
||||
|
||||
await this.page.getByRole('button', { name: 'Add' }).click();
|
||||
await this.page
|
||||
.getByTestId('schedule-edit-modal')
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 32 KiB |
@@ -155,6 +155,8 @@ test.describe('Transactions', () => {
|
||||
await expect(transaction.category.locator('input')).toHaveValue('Transfer');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
const balanceBeforeTransaction =
|
||||
await accountPage.accountBalance.textContent();
|
||||
await accountPage.addEnteredTransaction();
|
||||
|
||||
transaction = accountPage.getNthTransaction(0);
|
||||
@@ -163,6 +165,14 @@ test.describe('Transactions', () => {
|
||||
await expect(transaction.category).toHaveText('Transfer');
|
||||
await expect(transaction.debit).toHaveText('12.34');
|
||||
await expect(transaction.credit).toHaveText('');
|
||||
|
||||
// Wait for balance to update after adding transaction
|
||||
await expect(async () => {
|
||||
const balanceAfterTransaction =
|
||||
await accountPage.accountBalance.textContent();
|
||||
expect(balanceAfterTransaction).not.toBe(balanceBeforeTransaction);
|
||||
}).toPass();
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 93 KiB |
@@ -7,7 +7,6 @@
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Actual</title>
|
||||
<link rel="canonical" href="/" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
@@ -108,10 +107,6 @@
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.js-focus-visible :focus:not(.focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "25.9.0",
|
||||
"version": "25.10.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
@@ -8,38 +8,36 @@
|
||||
"devDependencies": {
|
||||
"@actual-app/components": "workspace:*",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@fontsource/redacted-script": "^5.2.5",
|
||||
"@fontsource/redacted-script": "^5.2.8",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@swc/core": "^1.11.24",
|
||||
"@swc/core": "^1.13.5",
|
||||
"@swc/helpers": "^0.5.17",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "^4",
|
||||
"@types/promise-retry": "^1.1.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/react-grid-layout": "^1",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "7.6.2",
|
||||
"focus-visible": "^4.1.5",
|
||||
"i18next": "^25.2.1",
|
||||
"downshift": "9.0.10",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-parser": "^9.3.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"inter-ui": "^3.19.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loot-core": "workspace:*",
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
@@ -48,35 +46,34 @@
|
||||
"promise-retry": "^2.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "19.1.1",
|
||||
"react-aria": "^3.39.0",
|
||||
"react-aria-components": "^1.8.0",
|
||||
"react": "19.2.0",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-aria-components": "^1.13.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-dom": "19.2.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-hotkeys-hook": "^5.1.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.6.2",
|
||||
"react-router": "7.9.3",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "^10.0.0",
|
||||
"react-stately": "^3.37.0",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"recharts": "^2.15.3",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"sass": "^1.89.0",
|
||||
"rollup-plugin-visualizer": "^6.0.4",
|
||||
"sass": "^1.93.2",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
|
||||
@@ -313,6 +313,41 @@ export const linkAccountPluggyAi = createAppAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
type LinkAccountPluginPayload = {
|
||||
accountId: string;
|
||||
externalAccount: {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
balance: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
syncSource: 'plugin';
|
||||
providerSlug: string;
|
||||
upgradingId?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
export const linkAccountPlugin = createAppAsyncThunk(
|
||||
`${sliceName}/linkAccountPlugin`,
|
||||
async (
|
||||
{
|
||||
accountId: _accountId,
|
||||
externalAccount,
|
||||
providerSlug,
|
||||
upgradingId,
|
||||
}: LinkAccountPluginPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
await send('bank-sync-accounts-link', {
|
||||
providerSlug,
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
});
|
||||
dispatch(markPayeesDirty());
|
||||
dispatch(markAccountsDirty());
|
||||
},
|
||||
);
|
||||
|
||||
function handleSyncResponse(
|
||||
accountId: AccountEntity['id'],
|
||||
res: SyncResponseWithErrors,
|
||||
|
||||
@@ -178,7 +178,6 @@ global.Actual = {
|
||||
// Wait for the app to reload
|
||||
await new Promise(() => {});
|
||||
},
|
||||
updateAppMenu: () => {},
|
||||
|
||||
ipcConnect: () => {},
|
||||
getServerSocket: async () => {
|
||||
@@ -191,35 +190,3 @@ global.Actual = {
|
||||
|
||||
moveBudgetDirectory: () => {},
|
||||
};
|
||||
|
||||
function inputFocused(e) {
|
||||
return (
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
// Cmd/Ctrl+o
|
||||
if (e.key === 'o') {
|
||||
e.preventDefault();
|
||||
window.__actionsForMenu.closeBudget();
|
||||
}
|
||||
// Cmd/Ctrl+z
|
||||
else if (e.key.toLowerCase() === 'z') {
|
||||
if (inputFocused(e)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
// Redo
|
||||
window.__actionsForMenu.redo();
|
||||
} else {
|
||||
// Undo
|
||||
window.__actionsForMenu.undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -212,10 +212,28 @@ export const moveCategoryGroup = createAppAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
function translateCategories(
|
||||
categories: CategoryEntity[] | undefined,
|
||||
): CategoryEntity[] | undefined {
|
||||
return categories?.map(cat => ({
|
||||
...cat,
|
||||
name:
|
||||
cat.name?.toLowerCase() === 'starting balances'
|
||||
? t('Starting Balances')
|
||||
: cat.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export const getCategories = createAppAsyncThunk(
|
||||
`${sliceName}/getCategories`,
|
||||
async () => {
|
||||
const categories: CategoryViews = await send('get-categories');
|
||||
categories.list = translateCategories(categories.list) as CategoryEntity[];
|
||||
categories.grouped.forEach(group => {
|
||||
group.categories = translateCategories(
|
||||
group.categories,
|
||||
) as CategoryEntity[];
|
||||
});
|
||||
return categories;
|
||||
},
|
||||
{
|
||||
@@ -233,6 +251,12 @@ export const reloadCategories = createAppAsyncThunk(
|
||||
`${sliceName}/reloadCategories`,
|
||||
async () => {
|
||||
const categories: CategoryViews = await send('get-categories');
|
||||
categories.list = translateCategories(categories.list) as CategoryEntity[];
|
||||
categories.grouped.forEach(group => {
|
||||
group.categories = translateCategories(
|
||||
group.categories,
|
||||
) as CategoryEntity[];
|
||||
});
|
||||
return categories;
|
||||
},
|
||||
);
|
||||
@@ -556,6 +580,7 @@ export const getCategoriesById = memoizeOne(
|
||||
res[cat.id] = cat;
|
||||
});
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
);
|
||||
@@ -584,6 +609,12 @@ function _loadCategories(
|
||||
categories: BudgetState['categories'],
|
||||
) {
|
||||
state.categories = categories;
|
||||
categories.list = translateCategories(categories.list) as CategoryEntity[];
|
||||
categories.grouped.forEach(group => {
|
||||
group.categories = translateCategories(
|
||||
group.categories,
|
||||
) as CategoryEntity[];
|
||||
});
|
||||
state.isCategoriesLoading = false;
|
||||
state.isCategoriesLoaded = true;
|
||||
state.isCategoriesDirty = false;
|
||||
|
||||
@@ -135,10 +135,6 @@ function AppInner() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, showErrorBoundary]);
|
||||
|
||||
useEffect(() => {
|
||||
global.Actual.updateAppMenu(budgetId);
|
||||
}, [budgetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userData?.tokenExpired) {
|
||||
dispatch(
|
||||
|
||||
@@ -97,7 +97,7 @@ export const HelpMenu = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useHotkeys('shift+?', () => setMenuOpen(true));
|
||||
useHotkeys('?', () => setMenuOpen(true), { useKey: true });
|
||||
|
||||
return (
|
||||
<SpaceBetween>
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as monthUtils from 'loot-core/shared/months';
|
||||
import { EditSyncAccount } from './banksync/EditSyncAccount';
|
||||
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
|
||||
import { AccountMenuModal } from './modals/AccountMenuModal';
|
||||
import { BankSyncInitialiseModal } from './modals/BankSyncInitialiseModal';
|
||||
import { BudgetAutomationsModal } from './modals/BudgetAutomationsModal';
|
||||
import { BudgetFileSelectionModal } from './modals/BudgetFileSelectionModal';
|
||||
import { BudgetPageMenuModal } from './modals/BudgetPageMenuModal';
|
||||
@@ -183,6 +184,9 @@ export function Modals() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'bank-sync-init':
|
||||
return <BankSyncInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'create-encryption-key':
|
||||
return <CreateEncryptionKeyModal key={key} {...modal.options} />;
|
||||
|
||||
|
||||
@@ -35,12 +35,14 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
development: SvgMoonStars,
|
||||
} as const;
|
||||
|
||||
type ThemeIconKey = keyof typeof themeIcons;
|
||||
|
||||
function onMenuSelect(newTheme: Theme) {
|
||||
setMenuOpen(false);
|
||||
switchTheme(newTheme);
|
||||
}
|
||||
|
||||
const Icon = themeIcons[theme] || SvgSun;
|
||||
const Icon = themeIcons[theme as ThemeIconKey] || SvgSun;
|
||||
|
||||
if (isNarrowWidth) {
|
||||
return null;
|
||||
|
||||
@@ -20,10 +20,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { listen } from 'loot-core/platform/client/fetch';
|
||||
import {
|
||||
isDevelopmentEnvironment,
|
||||
isElectron,
|
||||
} from 'loot-core/shared/environment';
|
||||
import { isDevelopmentEnvironment } from 'loot-core/shared/environment';
|
||||
import * as Platform from 'loot-core/shared/platform';
|
||||
|
||||
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
|
||||
@@ -352,7 +349,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
<PrivacyButton />
|
||||
{serverURL ? <SyncButton /> : null}
|
||||
<LoggedInUser />
|
||||
{!isElectron() && <HelpMenu />}
|
||||
<HelpMenu />
|
||||
</SpaceBetween>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -20,38 +20,67 @@ import { useDispatch } from '@desktop-client/redux';
|
||||
function useErrorMessage() {
|
||||
const { t } = useTranslation();
|
||||
function getErrorMessage(type: string, code: string) {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'ITEM_ERROR':
|
||||
switch (code.toUpperCase()) {
|
||||
case 'NO_ACCOUNTS':
|
||||
return t(
|
||||
'No open accounts could be found. Did you close the account? If so, unlink the account.',
|
||||
);
|
||||
case 'ITEM_LOGIN_REQUIRED':
|
||||
return t(
|
||||
'Your password or something else has changed with your bank and you need to login again.',
|
||||
);
|
||||
default:
|
||||
}
|
||||
break;
|
||||
// Handle standardized bank sync error codes
|
||||
switch (code.toUpperCase()) {
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return t(
|
||||
'Your credentials are invalid. Please reconfigure your bank connection.',
|
||||
);
|
||||
|
||||
case 'INVALID_INPUT':
|
||||
switch (code.toUpperCase()) {
|
||||
case 'INVALID_ACCESS_TOKEN':
|
||||
return t('Item is no longer authorized. You need to login again.');
|
||||
default:
|
||||
}
|
||||
break;
|
||||
case 'INVALID_ACCESS_TOKEN':
|
||||
return t(
|
||||
'Your access token is no longer valid. Please reconfigure your bank connection.',
|
||||
);
|
||||
|
||||
case 'UNAUTHORIZED':
|
||||
return t(
|
||||
'Access forbidden. Please check your permissions and reconfigure if needed.',
|
||||
);
|
||||
|
||||
case 'ACCOUNT_NOT_FOUND':
|
||||
return t(
|
||||
'Account not found. Please verify your account configuration.',
|
||||
);
|
||||
|
||||
case 'TRANSACTION_NOT_FOUND':
|
||||
return t('Transaction data not found. Please try again later.');
|
||||
|
||||
case 'SERVER_ERROR':
|
||||
return t(
|
||||
'The bank sync provider is experiencing issues. Please try again later.',
|
||||
);
|
||||
|
||||
case 'NETWORK_ERROR':
|
||||
return t(
|
||||
'Network error communicating with your bank. Please check your connection and try again.',
|
||||
);
|
||||
|
||||
case 'RATE_LIMIT':
|
||||
case 'RATE_LIMIT_EXCEEDED':
|
||||
return t('Rate limit exceeded for this item. Please try again later.');
|
||||
return t('Rate limit exceeded. Please try again later.');
|
||||
|
||||
case 'INVALID_REQUEST':
|
||||
return t(
|
||||
'Invalid request. Please check your account configuration and try again.',
|
||||
);
|
||||
|
||||
case 'ACCOUNT_LOCKED':
|
||||
return t(
|
||||
'Your account is locked. Please contact your bank for assistance.',
|
||||
);
|
||||
|
||||
case 'TIMED_OUT':
|
||||
return t('The request timed out. Please try again later.');
|
||||
|
||||
case 'INVALID_ACCESS_TOKEN':
|
||||
// Legacy error codes for backwards compatibility
|
||||
case 'NO_ACCOUNTS':
|
||||
return t(
|
||||
'Your SimpleFIN Access Token is no longer valid. Please reset and generate a new token.',
|
||||
'No open accounts could be found. Did you close the account? If so, unlink the account.',
|
||||
);
|
||||
|
||||
case 'ITEM_LOGIN_REQUIRED':
|
||||
return t(
|
||||
'Your password or something else has changed with your bank and you need to login again.',
|
||||
);
|
||||
|
||||
case 'ACCOUNT_NEEDS_ATTENTION':
|
||||
@@ -71,9 +100,22 @@ function useErrorMessage() {
|
||||
default:
|
||||
}
|
||||
|
||||
// Legacy type-based error handling
|
||||
switch (type.toUpperCase()) {
|
||||
case 'ITEM_ERROR':
|
||||
return t(
|
||||
'There was an error with your bank connection. Please try logging in again.',
|
||||
);
|
||||
|
||||
case 'INVALID_INPUT':
|
||||
return t('Invalid input. Please check your configuration.');
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
An internal error occurred. Try to log in again, or get{' '}
|
||||
An internal error occurred. Try to reconfigure your connection, or get{' '}
|
||||
<Link variant="external" to="https://actualbudget.org/contact/">
|
||||
in touch
|
||||
</Link>{' '}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
|
||||
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import * as query from '@desktop-client/queries';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { liveQuery } from '@desktop-client/queries/liveQuery';
|
||||
|
||||
const LABEL_WIDTH = 70;
|
||||
|
||||
@@ -32,11 +32,6 @@ type BalanceHistoryGraphProps = {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type Balance = {
|
||||
date: string;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
export function BalanceHistoryGraph({
|
||||
accountId,
|
||||
style,
|
||||
@@ -51,6 +46,11 @@ export function BalanceHistoryGraph({
|
||||
date: string;
|
||||
balance: number;
|
||||
} | null>(null);
|
||||
const [startingBalance, setStartingBalance] = useState<number | null>(null);
|
||||
const [monthlyTotals, setMonthlyTotals] = useState<Array<{
|
||||
date: string;
|
||||
balance: number;
|
||||
}> | null>(null);
|
||||
|
||||
const percentageChange = useMemo(() => {
|
||||
if (balanceData.length < 2) return 0;
|
||||
@@ -65,7 +65,70 @@ export function BalanceHistoryGraph({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBalanceHistory() {
|
||||
// Reset state when accountId changes
|
||||
setStartingBalance(null);
|
||||
setMonthlyTotals(null);
|
||||
setLoading(true);
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = subMonths(endDate, 12);
|
||||
|
||||
const startingBalanceQuery = query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
|
||||
})
|
||||
.calculate({ $sum: '$amount' });
|
||||
const monthlyTotalsQuery = query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
$and: [
|
||||
{ date: { $gte: monthUtils.firstDayOfMonth(startDate) } },
|
||||
{ date: { $lte: monthUtils.lastDayOfMonth(endDate) } },
|
||||
],
|
||||
})
|
||||
.groupBy({ $month: '$date' })
|
||||
.select([{ date: { $month: '$date' } }, { amount: { $sum: '$amount' } }]);
|
||||
|
||||
const startingBalanceLive: ReturnType<typeof liveQuery<number>> = liveQuery(
|
||||
startingBalanceQuery,
|
||||
{
|
||||
onData: (data: number[]) => {
|
||||
setStartingBalance(data[0] || 0);
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error fetching starting balance:', error);
|
||||
setLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const monthlyTotalsLive: ReturnType<
|
||||
typeof liveQuery<{ date: string; amount: number }>
|
||||
> = liveQuery(monthlyTotalsQuery, {
|
||||
onData: (data: Array<{ date: string; amount: number }>) => {
|
||||
setMonthlyTotals(
|
||||
data.map(d => ({
|
||||
date: d.date,
|
||||
balance: d.amount,
|
||||
})),
|
||||
);
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error fetching monthly totals:', error);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
startingBalanceLive?.unsubscribe();
|
||||
monthlyTotalsLive?.unsubscribe();
|
||||
};
|
||||
}, [accountId, locale]);
|
||||
|
||||
// Process data when both startingBalance and monthlyTotals are available
|
||||
useEffect(() => {
|
||||
if (startingBalance !== null && monthlyTotals !== null) {
|
||||
const endDate = new Date();
|
||||
const startDate = subMonths(endDate, 12);
|
||||
const months = eachMonthOfInterval({
|
||||
@@ -73,99 +136,70 @@ export function BalanceHistoryGraph({
|
||||
end: endDate,
|
||||
}).map(m => format(m, 'yyyy-MM'));
|
||||
|
||||
const [starting, totals]: [number, Balance[]] = await Promise.all([
|
||||
aqlQuery(
|
||||
query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
|
||||
})
|
||||
.calculate({ $sum: '$amount' }),
|
||||
).then(({ data }) => data),
|
||||
|
||||
aqlQuery(
|
||||
query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
$and: [
|
||||
{ date: { $gte: monthUtils.firstDayOfMonth(startDate) } },
|
||||
{ date: { $lte: monthUtils.lastDayOfMonth(endDate) } },
|
||||
],
|
||||
})
|
||||
.groupBy({ $month: '$date' })
|
||||
.select([
|
||||
{ date: { $month: '$date' } },
|
||||
{ amount: { $sum: '$amount' } },
|
||||
]),
|
||||
).then(({ data }) =>
|
||||
data.map((d: { date: string; amount: number }) => {
|
||||
return {
|
||||
date: d.date,
|
||||
balance: d.amount,
|
||||
};
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// calculate balances from sum of transactions
|
||||
let currentBalance = starting;
|
||||
totals.reverse().forEach(month => {
|
||||
currentBalance = currentBalance + month.balance;
|
||||
month.balance = currentBalance;
|
||||
});
|
||||
|
||||
// if the account doesn't have recent transactions
|
||||
// then the empty months will be missing from our data
|
||||
// so add in entries for those here
|
||||
if (totals.length === 0) {
|
||||
//handle case of no transactions in the last year
|
||||
months.forEach(expectedMonth =>
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: starting,
|
||||
}),
|
||||
);
|
||||
} else if (totals.length < months.length) {
|
||||
// iterate through each array together and add in missing data
|
||||
let totalsIndex = 0;
|
||||
let mostRecent = starting;
|
||||
months.forEach(expectedMonth => {
|
||||
if (totalsIndex > totals.length - 1) {
|
||||
// fill in the data at the end of the window
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
} else if (totals[totalsIndex].date === expectedMonth) {
|
||||
// a matched month
|
||||
mostRecent = totals[totalsIndex].balance;
|
||||
totalsIndex += 1;
|
||||
} else {
|
||||
// a missing month in the middle
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
}
|
||||
function processData(
|
||||
startingBalanceValue: number,
|
||||
monthlyTotalsValue: Array<{ date: string; balance: number }>,
|
||||
) {
|
||||
let currentBalance = startingBalanceValue;
|
||||
const totals = [...monthlyTotalsValue];
|
||||
totals.reverse().forEach(month => {
|
||||
currentBalance = currentBalance + month.balance;
|
||||
month.balance = currentBalance;
|
||||
});
|
||||
|
||||
// if the account doesn't have recent transactions
|
||||
// then the empty months will be missing from our data
|
||||
// so add in entries for those here
|
||||
if (totals.length === 0) {
|
||||
//handle case of no transactions in the last year
|
||||
months.forEach(expectedMonth =>
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: startingBalanceValue,
|
||||
}),
|
||||
);
|
||||
} else if (totals.length < months.length) {
|
||||
// iterate through each array together and add in missing data
|
||||
let totalsIndex = 0;
|
||||
let mostRecent = startingBalanceValue;
|
||||
months.forEach(expectedMonth => {
|
||||
if (totalsIndex > totals.length - 1) {
|
||||
// fill in the data at the end of the window
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
} else if (totals[totalsIndex].date === expectedMonth) {
|
||||
// a matched month
|
||||
mostRecent = totals[totalsIndex].balance;
|
||||
totalsIndex += 1;
|
||||
} else {
|
||||
// a missing month in the middle
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const balances = totals
|
||||
.sort((a, b) => monthUtils.differenceInCalendarMonths(a.date, b.date))
|
||||
.map(t => {
|
||||
return {
|
||||
balance: t.balance,
|
||||
date: monthUtils.format(t.date, 'MMM yyyy', locale),
|
||||
};
|
||||
});
|
||||
|
||||
setBalanceData(balances);
|
||||
setHoveredValue(balances[balances.length - 1]);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const balances = totals
|
||||
.sort((a, b) => monthUtils.differenceInCalendarMonths(a.date, b.date))
|
||||
.map(t => {
|
||||
return {
|
||||
balance: t.balance,
|
||||
date: monthUtils.format(t.date, 'MMM yyyy', locale),
|
||||
};
|
||||
});
|
||||
|
||||
setBalanceData(balances);
|
||||
setHoveredValue(balances[balances.length - 1]);
|
||||
setLoading(false);
|
||||
processData(startingBalance, monthlyTotals);
|
||||
}
|
||||
|
||||
fetchBalanceHistory();
|
||||
}, [accountId, locale]);
|
||||
}, [startingBalance, monthlyTotals, locale]);
|
||||
|
||||
// State to track if the chart is hovered (used to conditionally render PrivacyFilter)
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -204,7 +203,7 @@ export function AccountHeader({
|
||||
const isUsingServer = syncServerStatus !== 'no-server';
|
||||
const isServerOffline = syncServerStatus === 'offline';
|
||||
const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
|
||||
const [showNetWorthChartPref, setShowNetWorthChartPref] = useSyncedPref(
|
||||
const [showNetWorthChartPref, _setShowNetWorthChartPref] = useSyncedPref(
|
||||
`show-account-${accountId}-net-worth-chart`,
|
||||
);
|
||||
const showNetWorthChart = showNetWorthChartPref === 'true';
|
||||
@@ -233,30 +232,6 @@ export function AccountHeader({
|
||||
}
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const ele = graphRef.current;
|
||||
if (!ele) return;
|
||||
const clone = ele.cloneNode(true) as HTMLDivElement;
|
||||
Object.assign(clone.style, {
|
||||
visibility: 'hidden',
|
||||
display: 'flex',
|
||||
});
|
||||
ele.after(clone);
|
||||
if (clone.clientHeight < window.innerHeight * 0.15) {
|
||||
setShowNetWorthChartPref('true');
|
||||
} else {
|
||||
setShowNetWorthChartPref('false');
|
||||
}
|
||||
clone.remove();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [setShowNetWorthChartPref]);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+f, cmd+f, meta+f',
|
||||
|
||||
@@ -473,115 +473,127 @@ function SingleAutocomplete<T extends AutocompleteItem>({
|
||||
>
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
getInputProps({
|
||||
ref: inputRef,
|
||||
...inputProps,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
(() => {
|
||||
const { className, style, ...restInputProps } =
|
||||
inputProps || {};
|
||||
const downshiftProps = getInputProps({
|
||||
ref: inputRef,
|
||||
...restInputProps,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
|
||||
if (openOnFocus) {
|
||||
open();
|
||||
}
|
||||
},
|
||||
onBlur: e => {
|
||||
// Should this be e.nativeEvent
|
||||
e['preventDownshiftDefault'] = true;
|
||||
inputProps.onBlur?.(e);
|
||||
if (openOnFocus) {
|
||||
open();
|
||||
}
|
||||
},
|
||||
onBlur: e => {
|
||||
// Should this be e.nativeEvent
|
||||
e['preventDownshiftDefault'] = true;
|
||||
inputProps.onBlur?.(e);
|
||||
|
||||
if (!closeOnBlur) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemsViewRef.current?.contains(e.relatedTarget)) {
|
||||
// Do not close when the user clicks on any of the items.
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearOnBlur) {
|
||||
if (e.target.value === '') {
|
||||
onSelect?.(null, e.target.value);
|
||||
setSelectedItem(null);
|
||||
close();
|
||||
if (!closeOnBlur) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not using table behavior, reset the input on blur. Tables
|
||||
// handle saving the value on blur.
|
||||
const value = selectedItem ? getItemId(selectedItem) : null;
|
||||
|
||||
resetState(value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { onKeyDown } = inputProps || {};
|
||||
|
||||
// If the dropdown is open, an item is highlighted, and the user
|
||||
// pressed enter, always capture that and handle it ourselves
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
if (highlightedIndex != null) {
|
||||
if (
|
||||
inst.lastChangeType ===
|
||||
Downshift.stateChangeTypes.itemMouseEnter
|
||||
) {
|
||||
// If the last thing the user did was hover an item, intentionally
|
||||
// ignore the default behavior of selecting the item. It's too
|
||||
// common to accidentally hover an item and then save it
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Otherwise, stop propagation so that the table navigator
|
||||
// doesn't handle it
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (!strict) {
|
||||
// Handle it ourselves
|
||||
e.stopPropagation();
|
||||
onSelect(value, (e.target as HTMLInputElement).value);
|
||||
return onSelectAfter();
|
||||
} else {
|
||||
// No highlighted item, still allow the table to save the item
|
||||
// as `null`, even though we're allowing the table to move
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
} else if (shouldSaveFromKey(e)) {
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape ourselves
|
||||
if (e.key === 'Escape') {
|
||||
e.nativeEvent['preventDownshiftDefault'] = true;
|
||||
|
||||
if (!embedded) {
|
||||
if (itemsViewRef.current?.contains(e.relatedTarget)) {
|
||||
// Do not close when the user clicks on any of the items.
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
fireUpdate(
|
||||
onUpdate,
|
||||
strict,
|
||||
suggestions,
|
||||
null,
|
||||
getItemId(originalItem),
|
||||
);
|
||||
if (clearOnBlur) {
|
||||
if (e.target.value === '') {
|
||||
onSelect?.(null, e.target.value);
|
||||
setSelectedItem(null);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(getItemName(originalItem));
|
||||
setSelectedItem(
|
||||
findItem(strict, suggestions, originalItem),
|
||||
);
|
||||
setHighlightedIndex(null);
|
||||
if (embedded) {
|
||||
open();
|
||||
// If not using table behavior, reset the input on blur. Tables
|
||||
// handle saving the value on blur.
|
||||
const value = selectedItem
|
||||
? getItemId(selectedItem)
|
||||
: null;
|
||||
|
||||
resetState(value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { onKeyDown } = inputProps || {};
|
||||
|
||||
// If the dropdown is open, an item is highlighted, and the user
|
||||
// pressed enter, always capture that and handle it ourselves
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
if (highlightedIndex != null) {
|
||||
if (
|
||||
inst.lastChangeType ===
|
||||
Downshift.stateChangeTypes.itemMouseEnter
|
||||
) {
|
||||
// If the last thing the user did was hover an item, intentionally
|
||||
// ignore the default behavior of selecting the item. It's too
|
||||
// common to accidentally hover an item and then save it
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Otherwise, stop propagation so that the table navigator
|
||||
// doesn't handle it
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (!strict) {
|
||||
// Handle it ourselves
|
||||
e.stopPropagation();
|
||||
onSelect(value, (e.target as HTMLInputElement).value);
|
||||
return onSelectAfter();
|
||||
} else {
|
||||
// No highlighted item, still allow the table to save the item
|
||||
// as `null`, even though we're allowing the table to move
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
} else if (shouldSaveFromKey(e)) {
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape ourselves
|
||||
if (e.key === 'Escape') {
|
||||
e.nativeEvent['preventDownshiftDefault'] = true;
|
||||
|
||||
if (!embedded) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
fireUpdate(
|
||||
onUpdate,
|
||||
strict,
|
||||
suggestions,
|
||||
null,
|
||||
getItemId(originalItem),
|
||||
);
|
||||
|
||||
setValue(getItemName(originalItem));
|
||||
setSelectedItem(
|
||||
findItem(strict, suggestions, originalItem),
|
||||
);
|
||||
setHighlightedIndex(null);
|
||||
if (embedded) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...downshiftProps,
|
||||
...(className && { className }),
|
||||
...(style && { style }),
|
||||
};
|
||||
})(),
|
||||
)}
|
||||
</View>
|
||||
{isOpen &&
|
||||
|
||||
@@ -44,11 +44,25 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
|
||||
}
|
||||
|
||||
function extractPayeesAndHeaderNames(screen: Screen) {
|
||||
return [
|
||||
...screen
|
||||
.getByTestId('autocomplete')
|
||||
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
|
||||
]
|
||||
const autocompleteElement = screen.getByTestId('autocomplete');
|
||||
|
||||
// Get all elements that match either selector, but query them separately
|
||||
// and then sort by their position in the DOM to maintain document order
|
||||
const headers = [
|
||||
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR),
|
||||
];
|
||||
const items = [...autocompleteElement.querySelectorAll(PAYEE_SELECTOR)];
|
||||
|
||||
// Combine all elements and sort by their position in the DOM
|
||||
const allElements = [...headers, ...items];
|
||||
allElements.sort((a, b) => {
|
||||
// Compare document position to maintain DOM order
|
||||
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1;
|
||||
});
|
||||
|
||||
return allElements
|
||||
.map(e => e.getAttribute('data-testid'))
|
||||
.map(firstOrIncorrect);
|
||||
}
|
||||
@@ -154,15 +168,9 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
|
||||
];
|
||||
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
|
||||
|
||||
expect(
|
||||
[
|
||||
...screen
|
||||
.getByTestId('autocomplete')
|
||||
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
|
||||
]
|
||||
.map(e => e.getAttribute('data-testid'))
|
||||
.map(firstOrIncorrect),
|
||||
).toStrictEqual(expectedPayeeOrder);
|
||||
expect(extractPayeesAndHeaderNames(screen)).toStrictEqual(
|
||||
expectedPayeeOrder,
|
||||
);
|
||||
});
|
||||
|
||||
test('list with more than the maximum favorites only lists favorites', async () => {
|
||||
|
||||
@@ -171,34 +171,54 @@ function PayeeList({
|
||||
// entered
|
||||
|
||||
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
|
||||
return items.reduce(
|
||||
(acc, item, index) => {
|
||||
let currentIndex = 0;
|
||||
const result = items.reduce(
|
||||
(acc, item) => {
|
||||
if (item.id === 'new') {
|
||||
acc.newPayee = { ...item, highlightedIndex: index };
|
||||
acc.newPayee = { ...item };
|
||||
} else if (item.itemType === 'common_payee') {
|
||||
acc.suggestedPayees.push({ ...item, highlightedIndex: index });
|
||||
acc.suggestedPayees.push({ ...item });
|
||||
} else if (item.itemType === 'payee') {
|
||||
acc.payees.push({ ...item, highlightedIndex: index });
|
||||
acc.payees.push({ ...item });
|
||||
} else if (item.itemType === 'account') {
|
||||
acc.transferPayees.push({ ...item, highlightedIndex: index });
|
||||
acc.transferPayees.push({ ...item });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
newPayee: null as PayeeAutocompleteItem & {
|
||||
highlightedIndex: number;
|
||||
},
|
||||
suggestedPayees: [] as Array<
|
||||
PayeeAutocompleteItem & { highlightedIndex: number }
|
||||
>,
|
||||
payees: [] as Array<
|
||||
PayeeAutocompleteItem & { highlightedIndex: number }
|
||||
>,
|
||||
transferPayees: [] as Array<
|
||||
PayeeAutocompleteItem & { highlightedIndex: number }
|
||||
>,
|
||||
newPayee: null as PayeeAutocompleteItem | null,
|
||||
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
payees: [] as Array<PayeeAutocompleteItem>,
|
||||
transferPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
},
|
||||
);
|
||||
|
||||
// assign indexes in render order
|
||||
const newPayeeWithIndex = result.newPayee
|
||||
? { ...result.newPayee, highlightedIndex: currentIndex++ }
|
||||
: null;
|
||||
|
||||
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
const payeesWithIndex = result.payees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
const transferPayeesWithIndex = result.transferPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
return {
|
||||
newPayee: newPayeeWithIndex,
|
||||
suggestedPayees: suggestedPayeesWithIndex,
|
||||
payees: payeesWithIndex,
|
||||
transferPayees: transferPayeesWithIndex,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
// We limit the number of payees shown to 100.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Fragment, type ComponentProps } from 'react';
|
||||
import React, { type ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -27,7 +27,7 @@ export function ReportList<T extends { id: string; name: string }>({
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<Fragment>{ItemHeader({ title: t('Saved Reports') })}</Fragment>
|
||||
<ItemHeader title={t('Saved Reports')} />
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,6 @@ import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { mapField, friendlyOp } from 'loot-core/shared/rules';
|
||||
import { integerToCurrency } from 'loot-core/shared/util';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { FilterEditor } from './FiltersMenu';
|
||||
@@ -139,11 +138,7 @@ export function FilterExpression<T extends RuleConditionEntity>({
|
||||
<FilterEditor
|
||||
field={originalField}
|
||||
op={op}
|
||||
value={
|
||||
field === 'amount' && typeof value === 'number'
|
||||
? integerToCurrency(value)
|
||||
: value
|
||||
}
|
||||
value={value}
|
||||
options={options}
|
||||
onSave={onChange}
|
||||
onClose={() => setEditing(false)}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { updateFilterReducer } from './updateFilterReducer';
|
||||
|
||||
import { GenericInput } from '@desktop-client/components/util/GenericInput';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useTransactionFilters } from '@desktop-client/hooks/useTransactionFilters';
|
||||
|
||||
let isDatepickerClick = false;
|
||||
@@ -68,6 +69,7 @@ function ConfigureField({
|
||||
onApply,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const [subfield, setSubfield] = useState(initialSubfield);
|
||||
const inputRef = useRef();
|
||||
const prevOp = useRef(null);
|
||||
@@ -222,10 +224,33 @@ function ConfigureField({
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
let submitValue = value;
|
||||
|
||||
if (field === 'amount' && inputRef.current) {
|
||||
try {
|
||||
if (inputRef.current.getCurrentAmount) {
|
||||
submitValue = inputRef.current.getCurrentAmount();
|
||||
} else {
|
||||
const rawValue = inputRef.current.value || '';
|
||||
const parsed = format.fromEdit(rawValue, null);
|
||||
if (parsed == null) {
|
||||
submitValue = value; // keep previous if parsing failed
|
||||
} else {
|
||||
const opts = subfieldToOptions(field, subfield);
|
||||
submitValue =
|
||||
opts?.inflow || opts?.outflow ? Math.abs(parsed) : parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
submitValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
onApply({
|
||||
field,
|
||||
op,
|
||||
value,
|
||||
value: submitValue,
|
||||
options: subfieldToOptions(field, subfield),
|
||||
});
|
||||
}}
|
||||
@@ -244,9 +269,11 @@ function ConfigureField({
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
value={formattedValue}
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
onChange={v => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
|
||||
@@ -137,7 +137,7 @@ export const Checkbox = (props: CheckboxProps) => {
|
||||
backgroundColor: theme.buttonNormalDisabledBorder,
|
||||
},
|
||||
},
|
||||
'&.focus-visible:focus': {
|
||||
'&:focus-visible': {
|
||||
'::before': {
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
@@ -55,7 +56,10 @@ function Version() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{`App: v${window.Actual.ACTUAL_VERSION} | Server: ${version}`}
|
||||
<Trans>
|
||||
App: v{{ appVersion: window.Actual.ACTUAL_VERSION }} | Server:{' '}
|
||||
{{ serverVersion: version }}
|
||||
</Trans>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { type ReactNode, useRef, useState } from 'react';
|
||||
import { GridListItem, type GridListItemProps } from 'react-aria-components';
|
||||
import { useSpring, animated, config } from 'react-spring';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import { type WithRequired } from 'loot-core/types/util';
|
||||
|
||||
type ActionableGridListItemProps<T> = {
|
||||
actions?: ReactNode;
|
||||
actionsBackgroundColor?: string;
|
||||
actionsWidth?: number;
|
||||
children?: ReactNode;
|
||||
} & Omit<WithRequired<GridListItemProps<T>, 'value'>, 'children'>;
|
||||
|
||||
export function ActionableGridListItem<T extends object>({
|
||||
value,
|
||||
textValue,
|
||||
actions,
|
||||
actionsBackgroundColor = theme.errorBackground,
|
||||
actionsWidth = 100,
|
||||
children,
|
||||
onAction,
|
||||
...props
|
||||
}: ActionableGridListItemProps<T>) {
|
||||
const dragStartedRef = useRef(false);
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
|
||||
const hasActions = !!actions;
|
||||
|
||||
// Spring animation for the swipe
|
||||
const [{ x }, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
config: config.stiff,
|
||||
}));
|
||||
|
||||
// Handle drag gestures
|
||||
const bind = useDrag(
|
||||
({ active, movement: [mx], velocity: [vx] }) => {
|
||||
const startPos = isRevealed ? -actionsWidth : 0;
|
||||
const currentX = startPos + mx;
|
||||
|
||||
if (active) {
|
||||
dragStartedRef.current = true;
|
||||
api.start({
|
||||
x: Math.max(-actionsWidth, Math.min(0, currentX)),
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Snap to revealed (-actionsWidth) or closed (0) based on position and velocity
|
||||
const shouldReveal =
|
||||
currentX < -actionsWidth / 2 ||
|
||||
(vx < -0.5 && currentX < -actionsWidth / 5);
|
||||
|
||||
api.start({
|
||||
x: shouldReveal ? -actionsWidth : 0,
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
setIsRevealed(shouldReveal);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
axis: 'x',
|
||||
from: () => [isRevealed ? -actionsWidth : 0, 0],
|
||||
enabled: hasActions,
|
||||
},
|
||||
);
|
||||
|
||||
// Prevent onAction from firing when dragging or if a drag was started
|
||||
const handleAction = () => {
|
||||
// Only allow action if no drag was started
|
||||
if (!dragStartedRef.current) {
|
||||
onAction?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
{...props}
|
||||
value={value}
|
||||
textValue={textValue}
|
||||
style={{
|
||||
...styles.mobileListItem,
|
||||
padding: 0,
|
||||
backgroundColor: hasActions
|
||||
? actionsBackgroundColor
|
||||
: (styles.mobileListItem.backgroundColor ?? 'transparent'),
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
{...(hasActions ? bind() : {})}
|
||||
style={{
|
||||
...(hasActions
|
||||
? { transform: x.to(v => `translate3d(${v}px,0,0)`) }
|
||||
: {}),
|
||||
display: 'flex',
|
||||
touchAction: hasActions ? 'pan-y' : 'auto',
|
||||
cursor: hasActions ? 'grab' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
backgroundColor: theme.tableBackground,
|
||||
minWidth: '100%',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={handleAction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Actions that appear when swiped */}
|
||||
{hasActions && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: actionsBackgroundColor,
|
||||
minWidth: actionsWidth,
|
||||
}}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
</GridListItem>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
@@ -14,15 +14,20 @@ import { PayeesList } from './PayeesList';
|
||||
import { Search } from '@desktop-client/components/common/Search';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useSelector } from '@desktop-client/redux';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
|
||||
export function MobilePayeesPage() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const payees = usePayees();
|
||||
const { showUndoNotification } = useUndo();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [ruleCounts, setRuleCounts] = useState(new Map<string, number>());
|
||||
const { ruleCounts, isLoading: isRuleCountsLoading } = usePayeeRuleCounts();
|
||||
const isLoading = useSelector(
|
||||
s => s.payees.isPayeesLoading || s.payees.isCommonPayeesLoading,
|
||||
);
|
||||
@@ -33,16 +38,6 @@ export function MobilePayeesPage() {
|
||||
return payees.filter(p => getNormalisedString(p.name).includes(norm));
|
||||
}, [payees, filter]);
|
||||
|
||||
const refetchRuleCounts = useCallback(async () => {
|
||||
const counts = await send('payees-get-rule-counts');
|
||||
const countsMap = new Map(Object.entries(counts));
|
||||
setRuleCounts(countsMap);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetchRuleCounts();
|
||||
}, [refetchRuleCounts]);
|
||||
|
||||
const onSearchChange = useCallback((value: string) => {
|
||||
setFilter(value);
|
||||
}, []);
|
||||
@@ -85,6 +80,30 @@ export function MobilePayeesPage() {
|
||||
[navigate, ruleCounts],
|
||||
);
|
||||
|
||||
const handlePayeeDelete = useCallback(
|
||||
async (payee: PayeeEntity) => {
|
||||
try {
|
||||
await send('payees-batch-change', { deleted: [{ id: payee.id }] });
|
||||
showUndoNotification({
|
||||
message: t('Payee “{{name}}” deleted successfully', {
|
||||
name: payee.name,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete payee:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete payee. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, showUndoNotification, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Page header={<MobilePageHeader title={t('Payees')} />} padding={0}>
|
||||
<View
|
||||
@@ -114,8 +133,10 @@ export function MobilePayeesPage() {
|
||||
<PayeesList
|
||||
payees={filteredPayees}
|
||||
ruleCounts={ruleCounts}
|
||||
isRuleCountsLoading={isRuleCountsLoading}
|
||||
isLoading={isLoading}
|
||||
onPayeePress={handlePayeePress}
|
||||
onPayeeDelete={handlePayeeDelete}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
import { GridList } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
@@ -14,16 +15,22 @@ import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTa
|
||||
type PayeesListProps = {
|
||||
payees: PayeeEntity[];
|
||||
ruleCounts: Map<string, number>;
|
||||
isRuleCountsLoading?: boolean;
|
||||
isLoading?: boolean;
|
||||
onPayeePress: (payee: PayeeEntity) => void;
|
||||
onPayeeDelete: (payee: PayeeEntity) => void;
|
||||
};
|
||||
|
||||
export function PayeesList({
|
||||
payees,
|
||||
ruleCounts,
|
||||
isRuleCountsLoading = false,
|
||||
isLoading = false,
|
||||
onPayeePress,
|
||||
onPayeeDelete,
|
||||
}: PayeesListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading && payees.length === 0) {
|
||||
return (
|
||||
<View
|
||||
@@ -63,22 +70,33 @@ export function PayeesList({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
|
||||
>
|
||||
{payees.map(payee => (
|
||||
<PayeesListItem
|
||||
key={payee.id}
|
||||
payee={payee}
|
||||
ruleCount={ruleCounts.get(payee.id) ?? 0}
|
||||
onPress={() => onPayeePress(payee)}
|
||||
/>
|
||||
))}
|
||||
<View style={{ flex: 1 }}>
|
||||
<GridList
|
||||
aria-label={t('Payees')}
|
||||
aria-busy={isLoading || undefined}
|
||||
items={payees}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
dependencies={[ruleCounts, isRuleCountsLoading]}
|
||||
>
|
||||
{payee => (
|
||||
<PayeesListItem
|
||||
value={payee}
|
||||
ruleCount={ruleCounts.get(payee.id) ?? 0}
|
||||
isRuleCountLoading={isRuleCountsLoading}
|
||||
onAction={() => onPayeePress(payee)}
|
||||
onDelete={() => onPayeeDelete(payee)}
|
||||
/>
|
||||
)}
|
||||
</GridList>
|
||||
{isLoading && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
paddingTop: 20,
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { memo } from 'react';
|
||||
import { type GridListItemProps } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgBookmark } from '@actual-app/components/icons/v1';
|
||||
@@ -7,88 +8,108 @@ import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
|
||||
import { type PayeeEntity } from 'loot-core/types/models';
|
||||
import { type WithRequired } from 'loot-core/types/util';
|
||||
|
||||
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
|
||||
import { PayeeRuleCountLabel } from '@desktop-client/components/payees/PayeeRuleCountLabel';
|
||||
|
||||
type PayeesListItemProps = {
|
||||
payee: PayeeEntity;
|
||||
ruleCount: number;
|
||||
onPress: () => void;
|
||||
};
|
||||
isRuleCountLoading?: boolean;
|
||||
onDelete: () => void;
|
||||
} & WithRequired<GridListItemProps<PayeeEntity>, 'value'>;
|
||||
|
||||
export function PayeesListItem({
|
||||
payee,
|
||||
export const PayeesListItem = memo(function PayeeListItem({
|
||||
value: payee,
|
||||
ruleCount,
|
||||
onPress,
|
||||
isRuleCountLoading,
|
||||
onDelete,
|
||||
...props
|
||||
}: PayeesListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{
|
||||
minHeight: 56,
|
||||
width: '100%',
|
||||
borderRadius: 0,
|
||||
borderWidth: '0 0 1px 0',
|
||||
borderColor: theme.tableBorder,
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.tableBackground,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '12px 16px',
|
||||
gap: 5,
|
||||
}}
|
||||
onPress={onPress}
|
||||
>
|
||||
{payee.favorite && (
|
||||
<SvgBookmark
|
||||
width={15}
|
||||
height={15}
|
||||
style={{
|
||||
color: theme.pageText,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SpaceBetween
|
||||
style={{
|
||||
justifyContent: 'space-between',
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: payee.transfer_acct ? theme.pageTextSubdued : theme.pageText,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
title={payee.name}
|
||||
>
|
||||
{(payee.transfer_acct ? t('Transfer: ') : '') + payee.name}
|
||||
</span>
|
||||
const label = payee.transfer_acct
|
||||
? t('Transfer: {{name}}', { name: payee.name })
|
||||
: payee.name;
|
||||
|
||||
<span
|
||||
return (
|
||||
<ActionableGridListItem
|
||||
id={payee.id}
|
||||
value={payee}
|
||||
textValue={label}
|
||||
actions={
|
||||
!payee.transfer_acct && (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onDelete}
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<SpaceBetween gap={5} style={{ flex: 1 }}>
|
||||
{payee.favorite && (
|
||||
<SvgBookmark
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
width={15}
|
||||
height={15}
|
||||
style={{
|
||||
color: theme.pageText,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SpaceBetween
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
padding: '3px 6px',
|
||||
backgroundColor: theme.noticeBackground,
|
||||
border: '1px solid ' + theme.noticeBackground,
|
||||
color: theme.noticeTextDark,
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
justifyContent: 'space-between',
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<PayeeRuleCountLabel count={ruleCount} style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: payee.transfer_acct
|
||||
? theme.pageTextSubdued
|
||||
: theme.pageText,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
padding: '3px 6px',
|
||||
backgroundColor: theme.noticeBackground,
|
||||
border: '1px solid ' + theme.noticeBackground,
|
||||
color: theme.noticeTextDark,
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<PayeeRuleCountLabel
|
||||
count={ruleCount}
|
||||
isLoading={isRuleCountLoading}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</span>
|
||||
</SpaceBetween>
|
||||
</SpaceBetween>
|
||||
</Button>
|
||||
</ActionableGridListItem>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
|
||||
@@ -7,12 +7,14 @@ import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { type RuleEntity, type NewRuleEntity } from 'loot-core/types/models';
|
||||
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { RuleEditor } from '@desktop-client/components/rules/RuleEditor';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
@@ -27,6 +29,21 @@ export function MobileRuleEditPage() {
|
||||
const [rule, setRule] = useState<RuleEntity | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(
|
||||
() =>
|
||||
rule?.id
|
||||
? q('schedules')
|
||||
.filter({ rule: rule.id, completed: false })
|
||||
.select('*')
|
||||
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule
|
||||
[rule?.id],
|
||||
),
|
||||
});
|
||||
|
||||
// Check if the current rule is linked to a schedule
|
||||
const isLinkedToSchedule = schedules.length > 0;
|
||||
|
||||
// Load rule by ID if we're in edit mode
|
||||
useEffect(() => {
|
||||
if (id && id !== 'new') {
|
||||
@@ -174,7 +191,7 @@ export function MobileRuleEditPage() {
|
||||
rule={defaultRule}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={isEditing ? handleDelete : undefined}
|
||||
onDelete={isEditing && !isLinkedToSchedule ? handleDelete : undefined}
|
||||
style={{
|
||||
paddingTop: 10,
|
||||
flex: 1,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { send, listen } from 'loot-core/platform/client/fetch';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { type RuleEntity } from 'loot-core/types/models';
|
||||
@@ -21,17 +22,19 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function MobileRulesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { showUndoNotification } = useUndo();
|
||||
const [visibleRulesParam] = useUrlParam('visible-rules');
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasMoreRules, setHasMoreRules] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const { schedules = [] } = useSchedules({
|
||||
@@ -76,16 +79,12 @@ export function MobileRulesPage() {
|
||||
);
|
||||
}, [visibleRules, filter, filterData, schedules]);
|
||||
|
||||
const loadRules = useCallback(async (append = false) => {
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await send('rules-get');
|
||||
const newRules = result || [];
|
||||
|
||||
setAllRules(prevRules =>
|
||||
append ? [...prevRules, ...newRules] : newRules,
|
||||
);
|
||||
setHasMoreRules(newRules.length === PAGE_SIZE);
|
||||
const rules = result || [];
|
||||
setAllRules(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to load rules:', error);
|
||||
setAllRules([]);
|
||||
@@ -98,6 +97,20 @@ export function MobileRulesPage() {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
// Listen for undo events to refresh rules list
|
||||
useEffect(() => {
|
||||
const onUndo = () => {
|
||||
loadRules();
|
||||
};
|
||||
|
||||
const lastUndoEvent = undo.getUndoState('undoEvent');
|
||||
if (lastUndoEvent) {
|
||||
onUndo();
|
||||
}
|
||||
|
||||
return listen('undo-event', onUndo);
|
||||
}, [loadRules]);
|
||||
|
||||
const handleRulePress = useCallback(
|
||||
(rule: RuleEntity) => {
|
||||
navigate(`/rules/${rule.id}`);
|
||||
@@ -105,12 +118,6 @@ export function MobileRulesPage() {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!isLoading && hasMoreRules && !filter) {
|
||||
loadRules(true);
|
||||
}
|
||||
}, [isLoading, hasMoreRules, filter, loadRules]);
|
||||
|
||||
const onSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setFilter(value);
|
||||
@@ -118,6 +125,47 @@ export function MobileRulesPage() {
|
||||
[setFilter],
|
||||
);
|
||||
|
||||
const handleRuleDelete = useCallback(
|
||||
async (rule: RuleEntity) => {
|
||||
try {
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
rule.id,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'warning',
|
||||
message: t(
|
||||
'This rule could not be deleted because it is linked to a schedule.',
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the rules list
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, showUndoNotification, t, loadRules],
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
header={
|
||||
@@ -153,7 +201,7 @@ export function MobileRulesPage() {
|
||||
rules={filteredRules}
|
||||
isLoading={isLoading}
|
||||
onRulePress={handleRulePress}
|
||||
onLoadMore={handleLoadMore}
|
||||
onRuleDelete={handleRuleDelete}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type UIEvent } from 'react';
|
||||
import { GridList } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
@@ -16,14 +16,14 @@ type RulesListProps = {
|
||||
rules: RuleEntity[];
|
||||
isLoading: boolean;
|
||||
onRulePress: (rule: RuleEntity) => void;
|
||||
onLoadMore?: () => void;
|
||||
onRuleDelete: (rule: RuleEntity) => void;
|
||||
};
|
||||
|
||||
export function RulesList({
|
||||
rules,
|
||||
isLoading,
|
||||
onRulePress,
|
||||
onLoadMore,
|
||||
onRuleDelete,
|
||||
}: RulesListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -65,32 +65,31 @@ export function RulesList({
|
||||
);
|
||||
}
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
if (!onLoadMore) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{rules.map(rule => (
|
||||
<RulesListItem
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onPress={() => onRulePress(rule)}
|
||||
/>
|
||||
))}
|
||||
<View style={{ flex: 1 }}>
|
||||
<GridList
|
||||
aria-label={t('Rules')}
|
||||
aria-busy={isLoading || undefined}
|
||||
items={rules}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{rule => (
|
||||
<RulesListItem
|
||||
value={rule}
|
||||
onAction={() => onRulePress(rule)}
|
||||
onDelete={() => onRuleDelete(rule)}
|
||||
/>
|
||||
)}
|
||||
</GridList>
|
||||
{isLoading && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
paddingTop: 20,
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type GridListItemProps } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { type RuleEntity } from 'loot-core/types/models';
|
||||
import { type WithRequired } from 'loot-core/types/util';
|
||||
|
||||
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
|
||||
import { ActionExpression } from '@desktop-client/components/rules/ActionExpression';
|
||||
import { ConditionExpression } from '@desktop-client/components/rules/ConditionExpression';
|
||||
import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
|
||||
type RulesListItemProps = {
|
||||
rule: RuleEntity;
|
||||
onPress: () => void;
|
||||
};
|
||||
onDelete: () => void;
|
||||
} & WithRequired<GridListItemProps<RuleEntity>, 'value'>;
|
||||
|
||||
export function RulesListItem({ rule, onPress }: RulesListItemProps) {
|
||||
export function RulesListItem({
|
||||
value: rule,
|
||||
onDelete,
|
||||
style,
|
||||
...props
|
||||
}: RulesListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Group actions by splitIndex to handle split transactions
|
||||
@@ -26,172 +33,172 @@ export function RulesListItem({ rule, onPress }: RulesListItemProps) {
|
||||
const hasSplits = actionSplits.length > 1;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{
|
||||
minHeight: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
borderRadius: 0,
|
||||
borderWidth: '0 0 1px 0',
|
||||
borderColor: theme.tableBorder,
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.tableBackground,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '8px 16px',
|
||||
gap: 12,
|
||||
}}
|
||||
onPress={onPress}
|
||||
<ActionableGridListItem
|
||||
id={rule.id}
|
||||
value={rule}
|
||||
textValue={t('Rule {{id}}', { id: rule.id })}
|
||||
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
|
||||
actions={
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onDelete}
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{/* Column 1: PRE/POST pill */}
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
paddingTop: 2, // Slight top padding to align with text baseline
|
||||
}}
|
||||
>
|
||||
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
|
||||
{/* Column 1: PRE/POST pill */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor:
|
||||
rule.stage === 'pre'
|
||||
? theme.noticeBackgroundLight
|
||||
: rule.stage === 'post'
|
||||
? theme.warningBackground
|
||||
: theme.pillBackgroundSelected,
|
||||
paddingLeft: 6,
|
||||
paddingRight: 6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
borderRadius: 3,
|
||||
flexShrink: 0,
|
||||
paddingTop: 2, // Slight top padding to align with text baseline
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<View
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color:
|
||||
backgroundColor:
|
||||
rule.stage === 'pre'
|
||||
? theme.noticeTextLight
|
||||
? theme.noticeBackgroundLight
|
||||
: rule.stage === 'post'
|
||||
? theme.warningText
|
||||
: theme.pillTextSelected,
|
||||
? theme.warningBackground
|
||||
: theme.pillBackgroundSelected,
|
||||
paddingLeft: 6,
|
||||
paddingRight: 6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{rule.stage === 'pre'
|
||||
? t('PRE')
|
||||
: rule.stage === 'post'
|
||||
? t('POST')
|
||||
: t('DEFAULT')}
|
||||
</span>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Column 2: IF and THEN blocks */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{/* IF conditions block */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextLight,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{t('IF')}
|
||||
</span>
|
||||
|
||||
{rule.conditions.map((condition, index) => (
|
||||
<View key={index} style={{ marginRight: 4, marginBottom: 2 }}>
|
||||
<ConditionExpression
|
||||
field={condition.field}
|
||||
op={condition.op}
|
||||
value={condition.value}
|
||||
options={condition.options}
|
||||
inline={true}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color:
|
||||
rule.stage === 'pre'
|
||||
? theme.noticeTextLight
|
||||
: rule.stage === 'post'
|
||||
? theme.warningText
|
||||
: theme.pillTextSelected,
|
||||
}}
|
||||
data-testid="rule-stage-badge"
|
||||
>
|
||||
{rule.stage === 'pre'
|
||||
? t('PRE')
|
||||
: rule.stage === 'post'
|
||||
? t('POST')
|
||||
: t('DEFAULT')}
|
||||
</span>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* THEN actions block */}
|
||||
{/* Column 2: IF and THEN blocks */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
{/* IF conditions block */}
|
||||
<SpaceBetween gap={6}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextLight,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{t('IF')}
|
||||
</span>
|
||||
|
||||
{rule.conditions.map((condition, index) => (
|
||||
<View key={index} style={{ marginRight: 4, marginBottom: 2 }}>
|
||||
<ConditionExpression
|
||||
field={condition.field}
|
||||
op={condition.op}
|
||||
value={condition.value}
|
||||
options={condition.options}
|
||||
inline={true}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</SpaceBetween>
|
||||
|
||||
{/* THEN actions block */}
|
||||
<View
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextLight,
|
||||
marginBottom: 2,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{t('THEN')}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextLight,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{t('THEN')}
|
||||
</span>
|
||||
|
||||
{hasSplits
|
||||
? actionSplits.map((split, i) => (
|
||||
<View
|
||||
key={split.id}
|
||||
style={{
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
marginTop: i > 0 ? 4 : 0,
|
||||
padding: '6px',
|
||||
borderColor: theme.tableBorder,
|
||||
borderWidth: '1px',
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
{hasSplits
|
||||
? actionSplits.map((split, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: theme.pageTextLight,
|
||||
marginBottom: 4,
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
marginTop: i > 0 ? 4 : 0,
|
||||
padding: '6px',
|
||||
borderColor: theme.tableBorder,
|
||||
borderWidth: '1px',
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
>
|
||||
{i ? t('Split {{num}}', { num: i }) : t('Apply to all')}
|
||||
</span>
|
||||
{split.actions.map((action, j) => (
|
||||
<View
|
||||
key={j}
|
||||
<span
|
||||
style={{
|
||||
marginBottom: j !== split.actions.length - 1 ? 2 : 0,
|
||||
maxWidth: '100%',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: theme.pageTextLight,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<ActionExpression {...action} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
: rule.actions.map((action, index) => (
|
||||
<View key={index} style={{ marginBottom: 2, maxWidth: '100%' }}>
|
||||
<ActionExpression {...action} />
|
||||
</View>
|
||||
))}
|
||||
{i ? t('Split {{num}}', { num: i }) : t('Apply to all')}
|
||||
</span>
|
||||
{split.actions.map((action, j) => (
|
||||
<View
|
||||
key={j}
|
||||
style={{
|
||||
marginBottom: j !== split.actions.length - 1 ? 2 : 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<ActionExpression {...action} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
: rule.actions.map((action, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={{ marginBottom: 2, maxWidth: '100%' }}
|
||||
>
|
||||
<ActionExpression {...action} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
</ActionableGridListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +203,6 @@ export function TransactionList({
|
||||
items={section.transactions.filter(
|
||||
t => !isPreviewId(t.id) || !t.is_child,
|
||||
)}
|
||||
addIdAndValue
|
||||
>
|
||||
{transaction => (
|
||||
<TransactionListItem
|
||||
|
||||
@@ -71,8 +71,9 @@ const getScheduleIconStyle = ({ isPreview }: { isPreview: boolean }) => ({
|
||||
color: isPreview ? theme.pageTextLight : theme.menuItemText,
|
||||
});
|
||||
|
||||
type TransactionListItemProps = ComponentPropsWithoutRef<
|
||||
typeof ListBoxItem<TransactionEntity>
|
||||
type TransactionListItemProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof ListBoxItem<TransactionEntity>>,
|
||||
'onPress'
|
||||
> & {
|
||||
onPress: (transaction: TransactionEntity) => void;
|
||||
onLongPress: (transaction: TransactionEntity) => void;
|
||||
|
||||