mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 04:02:38 -05:00
Compare commits
9 Commits
bugfix/plu
...
sync-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef14e1a79 | ||
|
|
1e5d5b9b78 | ||
|
|
33f6ae7f91 | ||
|
|
50fba76c47 | ||
|
|
744ae1625d | ||
|
|
9dda58b61d | ||
|
|
734bb86126 | ||
|
|
efb0d80aa4 | ||
|
|
605206d2f7 |
605
PLUGIN_ARCHITECTURE.md
Normal file
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
|
||||
@@ -74,12 +74,15 @@ 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/',
|
||||
@@ -88,18 +91,13 @@ export default pluginTypescript.config(
|
||||
'packages/desktop-client/build-stats/',
|
||||
'packages/desktop-client/public/kcab/',
|
||||
'packages/desktop-client/public/data/',
|
||||
'packages/desktop-client/**/node_modules/*',
|
||||
'packages/desktop-client/node_modules/',
|
||||
'packages/desktop-client/test-results/',
|
||||
'packages/desktop-client/playwright-report/',
|
||||
'packages/desktop-electron/client-build/',
|
||||
'packages/desktop-electron/build/',
|
||||
'packages/desktop-electron/dist/',
|
||||
'packages/loot-core/**/node_modules/*',
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
'packages/plugins-service/dist/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
],
|
||||
@@ -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',
|
||||
|
||||
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
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
|
||||
|
||||
Binary file not shown.
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
459
packages/bank-sync-plugin-pluggy.ai/dist/index.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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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"]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
|
||||
@@ -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>{' '}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Error } from '@desktop-client/components/alerts';
|
||||
import {
|
||||
Modal,
|
||||
ModalButtons,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
type BankSyncInitialiseProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'bank-sync-init' }
|
||||
>['options'];
|
||||
|
||||
export function BankSyncInitialiseModal({
|
||||
providerSlug: _providerSlug,
|
||||
providerDisplayName,
|
||||
onSuccess,
|
||||
}: BankSyncInitialiseProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentialsJson, setCredentialsJson] = useState('');
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function onSubmit(close: () => void) {
|
||||
if (!credentialsJson.trim()) {
|
||||
setIsValid(false);
|
||||
setError(t('Credentials JSON is required.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON
|
||||
try {
|
||||
const parsedCredentials = JSON.parse(credentialsJson);
|
||||
setIsValid(true);
|
||||
onSuccess(parsedCredentials);
|
||||
close();
|
||||
} catch (err) {
|
||||
setIsValid(false);
|
||||
setError(t('Invalid JSON format. Please check your input.'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal name="bank-sync-init" containerProps={{ style: { width: '40vw' } }}>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Set up {{provider}}', { provider: providerDisplayName })}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter your API credentials as a JSON object. The plugin will
|
||||
securely store these credentials.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={{ fontSize: 12, color: 'var(--color-n8)' }}>
|
||||
<Trans>Example:</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: 'var(--color-n1)',
|
||||
padding: 10,
|
||||
borderRadius: 4,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable actual/typography */}
|
||||
{`{
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"itemIds": "id1,id2,id3"
|
||||
}`}
|
||||
{/* eslint-enable actual/typography */}
|
||||
</View>
|
||||
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Credentials (JSON):')}
|
||||
htmlFor="credentials-field"
|
||||
/>
|
||||
<InitialFocus>
|
||||
<textarea
|
||||
id="credentials-field"
|
||||
value={credentialsJson}
|
||||
onChange={e => {
|
||||
setCredentialsJson(e.target.value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '8px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--color-n1)',
|
||||
color: 'var(--color-n9)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
// eslint-disable-next-line actual/typography
|
||||
placeholder='{"clientId": "…", "clientSecret": "…"}'
|
||||
/>
|
||||
</InitialFocus>
|
||||
</FormField>
|
||||
|
||||
{!isValid && <Error>{error}</Error>}
|
||||
</View>
|
||||
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
onSubmit(close);
|
||||
}}
|
||||
>
|
||||
<Trans>Save and continue</Trans>
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { useMultiuserEnabled } from '@desktop-client/components/ServerContext';
|
||||
import { authorizeBank } from '@desktop-client/gocardless';
|
||||
import {
|
||||
useBankSyncProviders,
|
||||
type BankSyncProvider,
|
||||
} from '@desktop-client/hooks/useBankSyncProviders';
|
||||
import { useBankSyncStatus } from '@desktop-client/hooks/useBankSyncStatus';
|
||||
import { useGoCardlessStatus } from '@desktop-client/hooks/useGoCardlessStatus';
|
||||
import { usePluggyAiStatus } from '@desktop-client/hooks/usePluggyAiStatus';
|
||||
import { useSimpleFinStatus } from '@desktop-client/hooks/useSimpleFinStatus';
|
||||
@@ -41,6 +46,84 @@ type CreateAccountModalProps = Extract<
|
||||
{ name: 'add-account' }
|
||||
>['options'];
|
||||
|
||||
// Separate component for each plugin provider button
|
||||
function PluginProviderButton({
|
||||
provider,
|
||||
syncServerStatus,
|
||||
onConnectProvider,
|
||||
onResetCredentials,
|
||||
}: {
|
||||
provider: BankSyncProvider;
|
||||
syncServerStatus: string;
|
||||
onConnectProvider: (
|
||||
provider: BankSyncProvider,
|
||||
isConfigured: boolean,
|
||||
) => void;
|
||||
onResetCredentials: (provider: BankSyncProvider) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { configured: isConfigured, isLoading: statusLoading } =
|
||||
useBankSyncStatus(provider.slug);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
isLoading={statusLoading}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={() => onConnectProvider(provider, isConfigured)}
|
||||
>
|
||||
{isConfigured
|
||||
? t('Link account with {{provider}}', {
|
||||
provider: provider.displayName,
|
||||
})
|
||||
: t('Set up {{provider}}', { provider: provider.displayName })}
|
||||
</ButtonWithLoading>
|
||||
{isConfigured && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Bank sync provider options')}
|
||||
style={{
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: 'currentColor',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Popover>
|
||||
<Menu
|
||||
onMenuSelect={itemId => {
|
||||
if (itemId === 'reconfigure') {
|
||||
onResetCredentials(provider);
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reconfigure {{provider}}', {
|
||||
provider: provider.displayName,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAccountModal({
|
||||
upgradingAccountId,
|
||||
}: CreateAccountModalProps) {
|
||||
@@ -60,6 +143,9 @@ export function CreateAccountModal({
|
||||
const { hasPermission } = useAuth();
|
||||
const multiuserEnabled = useMultiuserEnabled();
|
||||
|
||||
// Plugin providers
|
||||
const { providers: pluginProviders } = useBankSyncProviders();
|
||||
|
||||
const onConnectGoCardless = () => {
|
||||
if (!isGoCardlessSetupComplete) {
|
||||
onGoCardlessInit();
|
||||
@@ -309,6 +395,97 @@ export function CreateAccountModal({
|
||||
dispatch(pushModal({ modal: { name: 'add-local-account' } }));
|
||||
};
|
||||
|
||||
// Plugin provider handlers
|
||||
const onConnectPluginProvider = async (
|
||||
provider: BankSyncProvider,
|
||||
isConfigured: boolean,
|
||||
credentials?: Record<string, string>,
|
||||
) => {
|
||||
if (!isConfigured) {
|
||||
// Show initialization modal for setting up credentials
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'bank-sync-init',
|
||||
options: {
|
||||
providerSlug: provider.slug,
|
||||
providerDisplayName: provider.displayName,
|
||||
onSuccess: async (credentials: Record<string, string>) => {
|
||||
// The plugin system handles credentials internally
|
||||
// After setup, try to connect again
|
||||
onConnectPluginProvider(provider, true, credentials);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If configured, fetch and display accounts
|
||||
try {
|
||||
const results = (await send('bank-sync-accounts', {
|
||||
providerSlug: provider.slug,
|
||||
credentials,
|
||||
})) as any;
|
||||
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
|
||||
// The response should already be in the correct format for select-linked-accounts
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts: results.accounts || [],
|
||||
syncSource: 'plugin' as const,
|
||||
providerSlug: provider.slug,
|
||||
upgradingAccountId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error fetching plugin accounts:', err);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error fetching accounts'),
|
||||
message: (err as Error).message,
|
||||
timeout: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onResetPluginProviderCredentials = async (
|
||||
provider: BankSyncProvider,
|
||||
) => {
|
||||
// Open the configuration modal to allow user to enter new credentials
|
||||
// This will override the old credentials when they complete the setup
|
||||
//onConnectPluginProvider(provider, true);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'bank-sync-init',
|
||||
options: {
|
||||
providerSlug: provider.slug,
|
||||
providerDisplayName: provider.displayName,
|
||||
onSuccess: async (credentials: Record<string, string>) => {
|
||||
// The plugin system handles credentials internally
|
||||
// After setup, try to connect again
|
||||
onConnectPluginProvider(provider, true, credentials);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const { configuredGoCardless } = useGoCardlessStatus();
|
||||
useEffect(() => {
|
||||
setIsGoCardlessSetupComplete(configuredGoCardless);
|
||||
@@ -579,6 +756,42 @@ export function CreateAccountModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Plugin-based bank sync providers */}
|
||||
{canSetSecrets && pluginProviders.length > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
lineHeight: '1.4em',
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
marginTop: '18px',
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
<strong>Plugin-based bank sync providers:</strong>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{canSetSecrets &&
|
||||
pluginProviders.map(provider => (
|
||||
<View
|
||||
key={provider.slug}
|
||||
style={{ gap: 10, marginTop: '18px' }}
|
||||
>
|
||||
<PluginProviderButton
|
||||
provider={provider}
|
||||
syncServerStatus={syncServerStatus}
|
||||
onConnectProvider={onConnectPluginProvider}
|
||||
onResetCredentials={onResetPluginProviderCredentials}
|
||||
/>
|
||||
{provider.description && (
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
{provider.description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{(!isGoCardlessSetupComplete ||
|
||||
!isSimpleFinSetupComplete ||
|
||||
!isPluggyAiSetupComplete) &&
|
||||
@@ -589,9 +802,9 @@ export function CreateAccountModal({
|
||||
secrets. Please contact an Admin to configure
|
||||
</Trans>{' '}
|
||||
{[
|
||||
isGoCardlessSetupComplete ? '' : 'GoCardless',
|
||||
isGoCardlessSetupComplete ? '' : t('GoCardless'),
|
||||
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
|
||||
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
|
||||
isPluggyAiSetupComplete ? '' : t('Pluggy.ai'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' or ')}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
linkAccount,
|
||||
linkAccountPluggyAi,
|
||||
linkAccountSimpleFin,
|
||||
linkAccountPlugin,
|
||||
unlinkAccount,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
import {
|
||||
@@ -61,50 +62,103 @@ export type SelectLinkedAccountsModalProps =
|
||||
requisitionId: string;
|
||||
externalAccounts: SyncServerGoCardlessAccount[];
|
||||
syncSource: 'goCardless';
|
||||
providerSlug?: undefined;
|
||||
upgradingAccountId?: AccountEntity['id'];
|
||||
onSuccess?: (account: AccountEntity) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerSimpleFinAccount[];
|
||||
syncSource: 'simpleFin';
|
||||
providerSlug?: undefined;
|
||||
upgradingAccountId?: AccountEntity['id'];
|
||||
onSuccess?: (account: AccountEntity) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerPluggyAiAccount[];
|
||||
syncSource: 'pluggyai';
|
||||
providerSlug?: undefined;
|
||||
upgradingAccountId?: AccountEntity['id'];
|
||||
onSuccess?: (account: AccountEntity) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: Array<{
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
balance: number;
|
||||
[key: string]: string | number;
|
||||
}>;
|
||||
syncSource: 'plugin';
|
||||
providerSlug: string;
|
||||
upgradingAccountId?: AccountEntity['id'];
|
||||
onSuccess?: (account: AccountEntity) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function SelectLinkedAccountsModal({
|
||||
requisitionId = undefined,
|
||||
externalAccounts,
|
||||
syncSource,
|
||||
providerSlug,
|
||||
upgradingAccountId,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: SelectLinkedAccountsModalProps) {
|
||||
const propsWithSortedExternalAccounts =
|
||||
useMemo<SelectLinkedAccountsModalProps>(() => {
|
||||
const toSort = externalAccounts ? [...externalAccounts] : [];
|
||||
toSort.sort(
|
||||
(a, b) =>
|
||||
getInstitutionName(a)?.localeCompare(getInstitutionName(b)) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
switch (syncSource) {
|
||||
case 'simpleFin':
|
||||
return {
|
||||
syncSource: 'simpleFin',
|
||||
externalAccounts: toSort as SyncServerSimpleFinAccount[],
|
||||
};
|
||||
case 'pluggyai':
|
||||
return {
|
||||
syncSource: 'pluggyai',
|
||||
externalAccounts: toSort as SyncServerPluggyAiAccount[],
|
||||
};
|
||||
case 'goCardless':
|
||||
return {
|
||||
syncSource: 'goCardless',
|
||||
requisitionId: requisitionId!,
|
||||
externalAccounts: toSort as SyncServerGoCardlessAccount[],
|
||||
};
|
||||
}
|
||||
}, [externalAccounts, syncSource, requisitionId]);
|
||||
const propsWithSortedExternalAccounts = useMemo(() => {
|
||||
const toSort = externalAccounts ? [...externalAccounts] : [];
|
||||
toSort.sort(
|
||||
(a, b) =>
|
||||
getInstitutionName(a)?.localeCompare(getInstitutionName(b)) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
switch (syncSource) {
|
||||
case 'simpleFin':
|
||||
return {
|
||||
syncSource: 'simpleFin',
|
||||
externalAccounts: toSort as SyncServerSimpleFinAccount[],
|
||||
} as const;
|
||||
case 'pluggyai':
|
||||
return {
|
||||
syncSource: 'pluggyai',
|
||||
externalAccounts: toSort as SyncServerPluggyAiAccount[],
|
||||
} as const;
|
||||
case 'plugin':
|
||||
return {
|
||||
syncSource: 'plugin',
|
||||
providerSlug: providerSlug as string,
|
||||
externalAccounts: toSort as Array<{
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
balance: number;
|
||||
[key: string]: string | number;
|
||||
}>,
|
||||
upgradingAccountId,
|
||||
onSuccess,
|
||||
onClose,
|
||||
} as const;
|
||||
case 'goCardless':
|
||||
return {
|
||||
syncSource: 'goCardless',
|
||||
requisitionId: requisitionId!,
|
||||
externalAccounts: toSort as SyncServerGoCardlessAccount[],
|
||||
} as const;
|
||||
}
|
||||
}, [
|
||||
externalAccounts,
|
||||
syncSource,
|
||||
requisitionId,
|
||||
providerSlug,
|
||||
upgradingAccountId,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -177,6 +231,27 @@ export function SelectLinkedAccountsModal({
|
||||
offBudget,
|
||||
}),
|
||||
);
|
||||
} else if (propsWithSortedExternalAccounts.syncSource === 'plugin') {
|
||||
const pluginProps = propsWithSortedExternalAccounts as Extract<
|
||||
typeof propsWithSortedExternalAccounts,
|
||||
{ syncSource: 'plugin' }
|
||||
>;
|
||||
dispatch(
|
||||
linkAccountPlugin({
|
||||
accountId: chosenExternalAccountId,
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
syncSource: 'plugin',
|
||||
providerSlug: pluginProps.providerSlug,
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
linkAccount({
|
||||
|
||||
63
packages/desktop-client/src/hooks/useBankSyncProviders.ts
Normal file
63
packages/desktop-client/src/hooks/useBankSyncProviders.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
export type BankSyncProvider = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
version: string;
|
||||
endpoints: {
|
||||
status: string;
|
||||
accounts: string;
|
||||
transactions: string;
|
||||
};
|
||||
requiresAuth: boolean;
|
||||
};
|
||||
|
||||
export function useBankSyncProviders() {
|
||||
const [providers, setProviders] = useState<BankSyncProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProviders() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await send('bank-sync-providers-list');
|
||||
|
||||
if (
|
||||
response &&
|
||||
typeof response === 'object' &&
|
||||
'providers' in response
|
||||
) {
|
||||
const typedResponse = response as {
|
||||
providers: BankSyncProvider[];
|
||||
};
|
||||
|
||||
setProviders(typedResponse.providers);
|
||||
} else {
|
||||
// Fallback for when backend is not implemented yet
|
||||
setError('Invalid response format from server');
|
||||
setProviders([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching bank sync providers:', err);
|
||||
setError((err as Error).message || 'Unknown error');
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
providers,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
75
packages/desktop-client/src/hooks/useBankSyncStatus.ts
Normal file
75
packages/desktop-client/src/hooks/useBankSyncStatus.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { useSyncServerStatus } from './useSyncServerStatus';
|
||||
|
||||
type ProviderStatus = {
|
||||
configured: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useBankSyncStatus(providerSlug: string) {
|
||||
const [status, setStatus] = useState<ProviderStatus>({
|
||||
configured: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setRefetchTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStatus() {
|
||||
if (!providerSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await send('bank-sync-status', {
|
||||
providerSlug,
|
||||
});
|
||||
|
||||
if (result && typeof result === 'object') {
|
||||
const typedResult = result as {
|
||||
configured?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
setStatus({
|
||||
configured: typedResult.configured || false,
|
||||
error: typedResult.error,
|
||||
});
|
||||
} else {
|
||||
// Fallback when backend is not implemented
|
||||
setStatus({
|
||||
configured: false,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching status for ${providerSlug}:`, error);
|
||||
setStatus({
|
||||
configured: false,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncServerStatus === 'online') {
|
||||
fetchStatus();
|
||||
}
|
||||
}, [providerSlug, syncServerStatus, refetchTrigger]);
|
||||
|
||||
return {
|
||||
configured: status.configured,
|
||||
isLoading,
|
||||
error: status.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -122,6 +122,14 @@ export type Modal =
|
||||
onSuccess: (data: GoCardlessToken) => Promise<void>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'bank-sync-init';
|
||||
options: {
|
||||
providerSlug: string;
|
||||
providerDisplayName: string;
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'delete-budget';
|
||||
options: { file: File };
|
||||
|
||||
@@ -18,9 +18,6 @@ module.exports = {
|
||||
create(context) {
|
||||
const whitelist = [
|
||||
'Actual',
|
||||
'GoCardless',
|
||||
'SimpleFIN',
|
||||
'Pluggy.ai',
|
||||
'YNAB',
|
||||
'nYNAB',
|
||||
'YNAB4',
|
||||
|
||||
@@ -63,6 +63,10 @@ export type AccountHandlers = {
|
||||
'gocardless-create-web-token': typeof createGoCardlessWebToken;
|
||||
'accounts-bank-sync': typeof accountsBankSync;
|
||||
'simplefin-batch-sync': typeof simpleFinBatchSync;
|
||||
'bank-sync-providers-list': typeof getPluginProviders;
|
||||
'bank-sync-status': typeof getPluginStatus;
|
||||
'bank-sync-accounts': typeof getPluginAccounts;
|
||||
'bank-sync-accounts-link': typeof linkPluginAccount;
|
||||
'transactions-import': typeof importTransactions;
|
||||
'account-unlink': typeof unlinkAccount;
|
||||
};
|
||||
@@ -320,6 +324,210 @@ async function linkPluggyAiAccount({
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function linkPluginAccount({
|
||||
providerSlug,
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget = false,
|
||||
}: {
|
||||
providerSlug: string;
|
||||
externalAccount: {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
balance: number;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
upgradingId?: AccountEntity['id'];
|
||||
offBudget?: boolean;
|
||||
}) {
|
||||
let id;
|
||||
// For plugin accounts, we'll use a generic bank entry or create one based on the provider
|
||||
const providerName =
|
||||
typeof externalAccount.institution === 'string'
|
||||
? externalAccount.institution
|
||||
: (externalAccount.institution as any)?.name || providerSlug;
|
||||
|
||||
const bank = await link.findOrCreateBank(
|
||||
{ name: providerName },
|
||||
providerSlug, // Use providerSlug as the bank identifier
|
||||
);
|
||||
|
||||
if (upgradingId) {
|
||||
const accRow = await db.first<db.DbAccount>(
|
||||
'SELECT * FROM accounts WHERE id = ?',
|
||||
[upgradingId],
|
||||
);
|
||||
|
||||
if (!accRow) {
|
||||
throw new Error(`Account with ID ${upgradingId} not found.`);
|
||||
}
|
||||
|
||||
id = accRow.id;
|
||||
await db.update('accounts', {
|
||||
id,
|
||||
account_id: externalAccount.account_id,
|
||||
bank: bank.id,
|
||||
account_sync_source: providerSlug,
|
||||
});
|
||||
} else {
|
||||
id = uuidv4();
|
||||
await db.insertWithUUID('accounts', {
|
||||
id,
|
||||
account_id: externalAccount.account_id,
|
||||
name: externalAccount.name,
|
||||
official_name: externalAccount.name,
|
||||
bank: bank.id,
|
||||
offbudget: offBudget ? 1 : 0,
|
||||
account_sync_source: providerSlug,
|
||||
});
|
||||
await db.insertPayee({
|
||||
name: '',
|
||||
transfer_acct: id,
|
||||
});
|
||||
}
|
||||
|
||||
await bankSync.syncAccount(
|
||||
undefined,
|
||||
undefined,
|
||||
id,
|
||||
externalAccount.account_id,
|
||||
bank.bank_id,
|
||||
);
|
||||
|
||||
await connection.send('sync-event', {
|
||||
type: 'success',
|
||||
tables: ['transactions'],
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function getPluginAccounts({
|
||||
providerSlug,
|
||||
credentials,
|
||||
}: {
|
||||
providerSlug: string;
|
||||
credentials?: Record<string, string>;
|
||||
}) {
|
||||
const server = getServer();
|
||||
if (!server) {
|
||||
throw new Error('No server configured');
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the plugin's accounts endpoint
|
||||
const pluginUrl = `${server.BASE_SERVER}/plugins-api/bank-sync/${providerSlug}/accounts`;
|
||||
|
||||
// Get user token for authentication
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const response = await post(
|
||||
pluginUrl,
|
||||
credentials,
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
null,
|
||||
{
|
||||
redirect: 'follow',
|
||||
},
|
||||
);
|
||||
|
||||
if (!('error' in response)) {
|
||||
return response;
|
||||
} else {
|
||||
throw new Error(response.error || 'Plugin error');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching plugin accounts:', error);
|
||||
throw new Error(String(error) || 'Failed to fetch plugin accounts');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPluginProviders() {
|
||||
const server = getServer();
|
||||
if (!server) {
|
||||
throw new Error('No server configured');
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the plugin system's bank sync list endpoint
|
||||
const pluginUrl = `${server.BASE_SERVER}/plugins-api/bank-sync/list`;
|
||||
|
||||
// Get user token for authentication
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const response = await get(pluginUrl, {
|
||||
headers: { 'X-ACTUAL-TOKEN': userToken },
|
||||
});
|
||||
|
||||
const data = JSON.parse(response);
|
||||
|
||||
if (data.status === 'ok') {
|
||||
return {
|
||||
providers: data.data.providers || [],
|
||||
};
|
||||
} else {
|
||||
throw new Error(data.error || 'Plugin error');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching plugin providers:', error);
|
||||
throw new Error(String(error) || 'Failed to fetch plugin providers');
|
||||
}
|
||||
}
|
||||
|
||||
export { getPluginProviders };
|
||||
|
||||
async function getPluginStatus({ providerSlug }: { providerSlug: string }) {
|
||||
const server = getServer();
|
||||
if (!server) {
|
||||
throw new Error('No server configured');
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the plugin's status endpoint
|
||||
const pluginUrl = `${server.BASE_SERVER}/plugins-api/bank-sync/${providerSlug}/status`;
|
||||
|
||||
// Get user token for authentication
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const response = await get(pluginUrl, {
|
||||
headers: { 'X-ACTUAL-TOKEN': userToken },
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
const data = JSON.parse(response);
|
||||
|
||||
if (data.status === 'ok') {
|
||||
return {
|
||||
configured: data.data?.configured || false,
|
||||
error: data.data?.error,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
configured: false,
|
||||
error: data.error || 'Plugin error',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking status for plugin ${providerSlug}:`, error);
|
||||
return {
|
||||
configured: false,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccount({
|
||||
name,
|
||||
balance = 0,
|
||||
@@ -854,21 +1062,25 @@ function handleSyncError(
|
||||
if (err instanceof BankSyncError || (err as any)?.type === 'BankSyncError') {
|
||||
const error = err as BankSyncError;
|
||||
|
||||
// Use the reason from plugin if available, otherwise use default message
|
||||
let message = 'Failed syncing account "' + acct.name + '."';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).reason) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
message = (error as any).reason;
|
||||
} else if (error.category === 'RATE_LIMIT_EXCEEDED') {
|
||||
message = `Failed syncing account ${acct.name}. Rate limit exceeded. Please try again later.`;
|
||||
}
|
||||
|
||||
const syncError = {
|
||||
type: 'SyncError',
|
||||
accountId: acct.id,
|
||||
message: 'Failed syncing account “' + acct.name + '.”',
|
||||
message,
|
||||
category: error.category,
|
||||
code: error.code,
|
||||
};
|
||||
|
||||
if (error.category === 'RATE_LIMIT_EXCEEDED') {
|
||||
return {
|
||||
...syncError,
|
||||
message: `Failed syncing account ${acct.name}. Rate limit exceeded. Please try again later.`,
|
||||
};
|
||||
}
|
||||
|
||||
return syncError;
|
||||
}
|
||||
|
||||
@@ -1220,6 +1432,10 @@ app.method('account-properties', getAccountProperties);
|
||||
app.method('gocardless-accounts-link', linkGoCardlessAccount);
|
||||
app.method('simplefin-accounts-link', linkSimpleFinAccount);
|
||||
app.method('pluggyai-accounts-link', linkPluggyAiAccount);
|
||||
app.method('bank-sync-providers-list', getPluginProviders);
|
||||
app.method('bank-sync-status', getPluginStatus);
|
||||
app.method('bank-sync-accounts', getPluginAccounts);
|
||||
app.method('bank-sync-accounts-link', linkPluginAccount);
|
||||
app.method('account-create', mutator(undoable(createAccount)));
|
||||
app.method('account-close', mutator(closeAccount));
|
||||
app.method('account-reopen', mutator(undoable(reopenAccount)));
|
||||
|
||||
@@ -34,11 +34,17 @@ import {
|
||||
mappingsFromString,
|
||||
} from '../util/custom-sync-mapping';
|
||||
|
||||
import { getPluginProviders } from './app';
|
||||
import { getStartingBalancePayee } from './payees';
|
||||
import { title } from './title';
|
||||
|
||||
function BankSyncError(type: string, code: string, details?: object) {
|
||||
return { type: 'BankSyncError', category: type, code, details };
|
||||
function BankSyncError(
|
||||
type: string,
|
||||
code: string,
|
||||
details?: object,
|
||||
reason?: string,
|
||||
) {
|
||||
return { type: 'BankSyncError', category: type, code, details, reason };
|
||||
}
|
||||
|
||||
function makeSplitTransaction(trans, subtransactions) {
|
||||
@@ -296,6 +302,54 @@ async function downloadPluggyAiTransactions(
|
||||
return retVal;
|
||||
}
|
||||
|
||||
async function downloadPluginTransactions(
|
||||
providerSlug: string,
|
||||
acctId: AccountEntity['id'],
|
||||
since: string,
|
||||
) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) return;
|
||||
|
||||
logger.log(`Pulling transactions from plugin ${providerSlug}`);
|
||||
|
||||
const res = await post(
|
||||
`${getServer().BASE_SERVER}/plugins-api/bank-sync/${providerSlug}/transactions`,
|
||||
{
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
60000,
|
||||
{
|
||||
redirect: 'follow',
|
||||
},
|
||||
);
|
||||
|
||||
if (res.error_code) {
|
||||
throw BankSyncError(
|
||||
res.error_type,
|
||||
res.error_code,
|
||||
undefined,
|
||||
res.reason,
|
||||
);
|
||||
} else if ('error' in res) {
|
||||
throw BankSyncError('UNKNOWN_ERROR', res.error, undefined, res.error);
|
||||
}
|
||||
|
||||
let retVal = {};
|
||||
const singleRes = res as BankSyncResponse;
|
||||
retVal = {
|
||||
transactions: singleRes.transactions.all,
|
||||
accountBalance: singleRes.balances,
|
||||
startingBalance: singleRes.startingBalance,
|
||||
};
|
||||
|
||||
logger.log('Response:', retVal);
|
||||
return retVal;
|
||||
}
|
||||
|
||||
async function resolvePayee(trans, payeeName, payeesToCreate) {
|
||||
if (trans.payee == null && payeeName) {
|
||||
// First check our registry of new payees (to avoid a db access)
|
||||
@@ -996,9 +1050,24 @@ export async function syncAccount(
|
||||
newAccount,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unrecognized bank-sync provider: ${acctRow.account_sync_source}`,
|
||||
debugger;
|
||||
// Check if it's a plugin provider
|
||||
const pluginProviders = await getPluginProviders();
|
||||
const isValidPlugin = pluginProviders.providers.some(
|
||||
provider => provider.slug === acctRow.account_sync_source,
|
||||
);
|
||||
|
||||
if (isValidPlugin) {
|
||||
download = await downloadPluginTransactions(
|
||||
acctRow.account_sync_source,
|
||||
acctId,
|
||||
syncStartDate,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unrecognized bank-sync provider: ${acctRow.account_sync_source}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return processBankSyncDownload(download, id, acctRow, newAccount);
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function post(
|
||||
data: unknown,
|
||||
headers = {},
|
||||
timeout: number | null = null,
|
||||
opts: RequestInit = {},
|
||||
) {
|
||||
let text: string;
|
||||
let res: Response;
|
||||
@@ -54,6 +55,7 @@ export async function post(
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
text = await res.text();
|
||||
|
||||
8
packages/plugins-core-sync-server/.gitignore
vendored
Normal file
8
packages/plugins-core-sync-server/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
*.tgz
|
||||
|
||||
159
packages/plugins-core-sync-server/README.md
Normal file
159
packages/plugins-core-sync-server/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# @actual-app/plugins-core-sync-server
|
||||
|
||||
Core plugin utilities for Actual sync-server plugin authors.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides the middleware and utilities needed to create plugins for the Actual sync-server. Plugin authors can use this to build Express-based plugins that communicate with the sync-server via IPC (Inter-Process Communication).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @actual-app/plugins-core-sync-server express
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Plugin Setup
|
||||
|
||||
Create a plugin that responds to HTTP requests through the sync-server:
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { attachPluginMiddleware } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Define your routes as you normally would
|
||||
app.get('/hello', (req, res) => {
|
||||
res.json({ message: 'Hello from plugin!' });
|
||||
});
|
||||
|
||||
app.post('/data', (req, res) => {
|
||||
const { name } = req.body;
|
||||
res.json({ received: name });
|
||||
});
|
||||
|
||||
// Note: You don't need to call app.listen()
|
||||
// The plugin runs as a forked process and communicates via IPC
|
||||
console.log('Plugin is ready');
|
||||
```
|
||||
|
||||
### Plugin Manifest
|
||||
|
||||
Each plugin must have a `manifest.ts` file for type safety:
|
||||
|
||||
```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'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public hello world endpoint',
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
This is automatically converted to `manifest.json` during the build process.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
├── src/
|
||||
│ ├── index.ts # Main plugin code
|
||||
│ └── manifest.ts # TypeScript manifest (with type safety)
|
||||
├── dist/
|
||||
│ ├── index.js # Compiled JavaScript
|
||||
│ └── manifest.js # Compiled manifest (for build process)
|
||||
├── manifest.json # JSON manifest (generated)
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── scripts/
|
||||
│ ├── build-manifest.js # Converts TS manifest to JSON
|
||||
│ └── build-zip.js # Creates distribution zip
|
||||
└── README.md
|
||||
```
|
||||
|
||||
The build process creates: `{packageName}.{version}.zip`
|
||||
|
||||
### Accessing Plugin Routes
|
||||
|
||||
Once your plugin is loaded by the sync-server, it will be accessible via:
|
||||
|
||||
```
|
||||
http://your-server/plugins-api/<plugin-slug>/<your-route>
|
||||
```
|
||||
|
||||
For example, if your plugin slug is `my-plugin` and you have a route `/hello`:
|
||||
|
||||
```
|
||||
http://your-server/plugins-api/my-plugin/hello
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `attachPluginMiddleware(app: Express): void`
|
||||
|
||||
Attaches the plugin middleware to your Express app. This sets up IPC communication with the sync-server.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `app`: Your Express application instance
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { attachPluginMiddleware } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
const app = express();
|
||||
attachPluginMiddleware(app);
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
The package exports TypeScript types for plugin development:
|
||||
|
||||
- `PluginRequest`: IPC request structure from sync-server
|
||||
- `PluginResponse`: IPC response structure to sync-server
|
||||
- `PluginError`: IPC error structure
|
||||
- `PluginReady`: Plugin ready message structure
|
||||
- `PluginManifest`: Plugin manifest file structure
|
||||
- `PluginRoute`: Route configuration for manifest
|
||||
- `AuthLevel`: Authentication level type ('anonymous', 'authenticated', 'admin')
|
||||
- `PluginExpressRequest`: Express Request with plugin context
|
||||
- `PluginExpressResponse`: Express Response type
|
||||
- `UserInfo`: User information extracted from request
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The sync-server loads plugins from the `plugins` directory
|
||||
2. Each plugin is forked as a child process
|
||||
3. HTTP requests to `/plugins-api/<plugin-slug>/*` are intercepted by the sync-server
|
||||
4. The sync-server sends the request data to the plugin via IPC
|
||||
5. The plugin's Express app processes the request
|
||||
6. The plugin sends the response back to sync-server via IPC
|
||||
7. The sync-server returns the response to the original HTTP client
|
||||
|
||||
This architecture allows plugin authors to write standard Express code without worrying about IPC details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1
packages/plugins-core-sync-server/dist/index.d.ts.map
vendored
Normal file
1
packages/plugins-core-sync-server/dist/index.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC3E,cAAc,SAAS,CAAC"}
|
||||
34
packages/plugins-core-sync-server/dist/index.js
vendored
Normal file
34
packages/plugins-core-sync-server/dist/index.js
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
/**
|
||||
* @actual-app/plugins-core-sync-server
|
||||
*
|
||||
* Core plugin utilities for Actual sync-server plugin authors
|
||||
*
|
||||
* This package provides the middleware and utilities needed to create
|
||||
* plugins for the Actual sync-server. Plugin authors can use this to
|
||||
* build Express-based plugins that communicate with the sync-server via IPC.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getSecrets = exports.saveSecrets = exports.getSecret = exports.saveSecret = exports.attachPluginMiddleware = void 0;
|
||||
var middleware_1 = require("./middleware");
|
||||
Object.defineProperty(exports, "attachPluginMiddleware", { enumerable: true, get: function () { return middleware_1.attachPluginMiddleware; } });
|
||||
var secrets_1 = require("./secrets");
|
||||
Object.defineProperty(exports, "saveSecret", { enumerable: true, get: function () { return secrets_1.saveSecret; } });
|
||||
Object.defineProperty(exports, "getSecret", { enumerable: true, get: function () { return secrets_1.getSecret; } });
|
||||
Object.defineProperty(exports, "saveSecrets", { enumerable: true, get: function () { return secrets_1.saveSecrets; } });
|
||||
Object.defineProperty(exports, "getSecrets", { enumerable: true, get: function () { return secrets_1.getSecrets; } });
|
||||
__exportStar(require("./types"), exports);
|
||||
1
packages/plugins-core-sync-server/dist/middleware.d.ts.map
vendored
Normal file
1
packages/plugins-core-sync-server/dist/middleware.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAqB,MAAM,SAAS,CAAC;AASrD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CA0BzD"}
|
||||
161
packages/plugins-core-sync-server/dist/middleware.js
vendored
Normal file
161
packages/plugins-core-sync-server/dist/middleware.js
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.attachPluginMiddleware = attachPluginMiddleware;
|
||||
/**
|
||||
* Attaches IPC communication handler to an Express app
|
||||
* This allows the plugin's Express app to receive requests from sync-server via IPC
|
||||
*/
|
||||
function attachPluginMiddleware(app) {
|
||||
if (!process.send) {
|
||||
console.warn('Not running as a forked process, plugin IPC will not work');
|
||||
return;
|
||||
}
|
||||
// Set up IPC message handler
|
||||
process.on('message', async (message) => {
|
||||
if (message.type !== 'request') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Simulate an HTTP request to the Express app
|
||||
await handleIPCRequest(app, message);
|
||||
}
|
||||
catch (error) {
|
||||
sendError(message.requestId, error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
});
|
||||
// Send ready message to sync-server
|
||||
const readyMessage = { type: 'ready' };
|
||||
process.send(readyMessage);
|
||||
}
|
||||
/**
|
||||
* Handle an IPC request by simulating an HTTP request to the Express app
|
||||
*/
|
||||
async function handleIPCRequest(app, message) {
|
||||
return new Promise(resolve => {
|
||||
// Create mock request object
|
||||
const requestHeaders = message.headers;
|
||||
const mockReq = {
|
||||
method: message.method,
|
||||
path: message.path,
|
||||
url: message.path +
|
||||
(message.query && Object.keys(message.query).length > 0
|
||||
? '?' +
|
||||
new URLSearchParams(message.query).toString()
|
||||
: ''),
|
||||
headers: requestHeaders,
|
||||
query: message.query,
|
||||
body: message.body,
|
||||
params: {},
|
||||
user: message.user, // Add user info for secrets access
|
||||
pluginSlug: message.pluginSlug, // Add plugin slug for namespaced secrets
|
||||
_body: true, // Mark body as already parsed to prevent body-parser from running
|
||||
get: function (name) {
|
||||
return requestHeaders?.[name.toLowerCase()];
|
||||
},
|
||||
};
|
||||
// Create mock response object
|
||||
let responseSent = false;
|
||||
const responseHeaders = {};
|
||||
let statusCode = 200;
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
status: function (code) {
|
||||
statusCode = code;
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
setHeader: function (name, value) {
|
||||
responseHeaders[name] = value;
|
||||
return this;
|
||||
},
|
||||
getHeader: function (name) {
|
||||
return responseHeaders[name];
|
||||
},
|
||||
getHeaders: function () {
|
||||
return responseHeaders;
|
||||
},
|
||||
send: function (body) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
sendResponse(message.requestId, statusCode, responseHeaders, body);
|
||||
resolve();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
json: function (body) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
responseHeaders['Content-Type'] = 'application/json';
|
||||
sendResponse(message.requestId, statusCode, responseHeaders, body);
|
||||
resolve();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
end: function (data) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
sendResponse(message.requestId, statusCode, responseHeaders, data);
|
||||
resolve();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
};
|
||||
// Use Express's internal router to handle the request
|
||||
try {
|
||||
// Call the app as a function - this is how Express handles requests
|
||||
app(mockReq, mockRes, (err) => {
|
||||
if (err && !responseSent) {
|
||||
responseSent = true;
|
||||
sendError(message.requestId, err instanceof Error ? err.message : 'Unknown error');
|
||||
resolve();
|
||||
}
|
||||
else if (!responseSent) {
|
||||
// No route matched, send 404
|
||||
responseSent = true;
|
||||
sendResponse(message.requestId, 404, { 'Content-Type': 'application/json' }, {
|
||||
error: 'not_found',
|
||||
message: 'Route not found',
|
||||
});
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
sendError(message.requestId, error instanceof Error ? error.message : 'Unknown error');
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Send a response back to sync-server via IPC
|
||||
*/
|
||||
function sendResponse(requestId, status, headers, body) {
|
||||
if (!process.send) {
|
||||
return;
|
||||
}
|
||||
const response = {
|
||||
type: 'response',
|
||||
requestId,
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
process.send(response);
|
||||
}
|
||||
/**
|
||||
* Send an error back to sync-server via IPC
|
||||
*/
|
||||
function sendError(requestId, error) {
|
||||
if (!process.send) {
|
||||
return;
|
||||
}
|
||||
const errorResponse = {
|
||||
type: 'error',
|
||||
requestId,
|
||||
error,
|
||||
};
|
||||
process.send(errorResponse);
|
||||
}
|
||||
1
packages/plugins-core-sync-server/dist/secrets.d.ts.map
vendored
Normal file
1
packages/plugins-core-sync-server/dist/secrets.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../src/secrets.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AA2ClC;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAwB/C;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyB7C;AAED;;GAEG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ/C;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAY1E"}
|
||||
114
packages/plugins-core-sync-server/dist/secrets.js
vendored
Normal file
114
packages/plugins-core-sync-server/dist/secrets.js
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.saveSecret = saveSecret;
|
||||
exports.getSecret = getSecret;
|
||||
exports.saveSecrets = saveSecrets;
|
||||
exports.getSecrets = getSecrets;
|
||||
/**
|
||||
* Helper to send IPC message to parent (sync-server)
|
||||
*/
|
||||
function sendIPC(message) {
|
||||
if (!process.send) {
|
||||
throw new Error('Not running as a forked process');
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = Math.random().toString(36).substring(7);
|
||||
const handler = (response) => {
|
||||
if (response.type === 'secret-response' &&
|
||||
response.messageId === messageId) {
|
||||
process.off('message', handler);
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
}
|
||||
else {
|
||||
resolve(response.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('message', handler);
|
||||
const messageToSend = {
|
||||
...(typeof message === 'object' && message !== null ? message : {}),
|
||||
messageId,
|
||||
};
|
||||
process.send(messageToSend);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Save a secret for the plugin
|
||||
* Secrets are namespaced by plugin slug automatically
|
||||
*/
|
||||
async function saveSecret(req, key, value) {
|
||||
const pluginSlug = req.pluginSlug;
|
||||
if (!pluginSlug) {
|
||||
return { success: false, error: 'Plugin slug not found' };
|
||||
}
|
||||
const secretName = `${pluginSlug}_${key}`;
|
||||
try {
|
||||
await sendIPC({
|
||||
type: 'secret-set',
|
||||
name: secretName,
|
||||
value,
|
||||
user: req.user,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Retrieve a secret for the plugin
|
||||
* Secrets are namespaced by plugin slug automatically
|
||||
*/
|
||||
async function getSecret(req, key) {
|
||||
const pluginSlug = req.pluginSlug;
|
||||
if (!pluginSlug) {
|
||||
return { error: 'Plugin slug not found' };
|
||||
}
|
||||
const secretName = `${pluginSlug}_${key}`;
|
||||
try {
|
||||
const result = await sendIPC({
|
||||
type: 'secret-get',
|
||||
name: secretName,
|
||||
user: req.user,
|
||||
});
|
||||
return { value: result.value };
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return { value: undefined };
|
||||
}
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save multiple secrets at once
|
||||
*/
|
||||
async function saveSecrets(req, secrets) {
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
const result = await saveSecret(req, key, value);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
/**
|
||||
* Get multiple secrets at once
|
||||
*/
|
||||
async function getSecrets(req, keys) {
|
||||
const values = {};
|
||||
for (const key of keys) {
|
||||
const result = await getSecret(req, key);
|
||||
if (result.error) {
|
||||
return { error: result.error };
|
||||
}
|
||||
values[key] = result.value;
|
||||
}
|
||||
return { values };
|
||||
}
|
||||
1
packages/plugins-core-sync-server/dist/types.d.ts.map
vendored
Normal file
1
packages/plugins-core-sync-server/dist/types.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAE5C;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACvD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACrD,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC5C,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,cAAc,GAAG,WAAW,GAAG,WAAW,CAAC;AAEvE;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG,eAAe,GAAG,OAAO,CAAC;AAEhE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,oBAAY,iBAAiB;IAC3B,mBAAmB,wBAAwB;IAC3C,oBAAoB,yBAAyB;IAC7C,YAAY,iBAAiB;IAC7B,iBAAiB,sBAAsB;IACvC,qBAAqB,0BAA0B;IAC/C,YAAY,iBAAiB;IAC7B,aAAa,kBAAkB;IAC/B,UAAU,eAAe;IACzB,eAAe,oBAAoB;IACnC,cAAc,mBAAmB;IACjC,aAAa,kBAAkB;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,iBAAiB,GAAG,MAAM,CAAC;IACvC,UAAU,EAAE,iBAAiB,GAAG,MAAM,CAAC;IACvC,MAAM,EAAE,OAAO,GAAG,UAAU,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAqB,SAAQ,OAAO;IACnD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;AAE7C;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB"}
|
||||
20
packages/plugins-core-sync-server/dist/types.js
vendored
Normal file
20
packages/plugins-core-sync-server/dist/types.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.BankSyncErrorCode = void 0;
|
||||
/**
|
||||
* Standardized error codes for bank sync plugins
|
||||
*/
|
||||
var BankSyncErrorCode;
|
||||
(function (BankSyncErrorCode) {
|
||||
BankSyncErrorCode["INVALID_CREDENTIALS"] = "INVALID_CREDENTIALS";
|
||||
BankSyncErrorCode["INVALID_ACCESS_TOKEN"] = "INVALID_ACCESS_TOKEN";
|
||||
BankSyncErrorCode["UNAUTHORIZED"] = "UNAUTHORIZED";
|
||||
BankSyncErrorCode["ACCOUNT_NOT_FOUND"] = "ACCOUNT_NOT_FOUND";
|
||||
BankSyncErrorCode["TRANSACTION_NOT_FOUND"] = "TRANSACTION_NOT_FOUND";
|
||||
BankSyncErrorCode["SERVER_ERROR"] = "SERVER_ERROR";
|
||||
BankSyncErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR";
|
||||
BankSyncErrorCode["RATE_LIMIT"] = "RATE_LIMIT";
|
||||
BankSyncErrorCode["INVALID_REQUEST"] = "INVALID_REQUEST";
|
||||
BankSyncErrorCode["ACCOUNT_LOCKED"] = "ACCOUNT_LOCKED";
|
||||
BankSyncErrorCode["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
|
||||
})(BankSyncErrorCode || (exports.BankSyncErrorCode = BankSyncErrorCode = {}));
|
||||
31
packages/plugins-core-sync-server/package.json
Normal file
31
packages/plugins-core-sync-server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@actual-app/plugins-core-sync-server",
|
||||
"version": "0.0.1",
|
||||
"description": "Core plugin utilities for Actual sync-server plugin authors",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"sync-server"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0"
|
||||
}
|
||||
}
|
||||
13
packages/plugins-core-sync-server/src/index.ts
Normal file
13
packages/plugins-core-sync-server/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @actual-app/plugins-core-sync-server
|
||||
*
|
||||
* Core plugin utilities for Actual sync-server plugin authors
|
||||
*
|
||||
* This package provides the middleware and utilities needed to create
|
||||
* plugins for the Actual sync-server. Plugin authors can use this to
|
||||
* build Express-based plugins that communicate with the sync-server via IPC.
|
||||
*/
|
||||
|
||||
export { attachPluginMiddleware } from './middleware';
|
||||
export { saveSecret, getSecret, saveSecrets, getSecrets } from './secrets';
|
||||
export * from './types';
|
||||
217
packages/plugins-core-sync-server/src/middleware.ts
Normal file
217
packages/plugins-core-sync-server/src/middleware.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Express, Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
PluginRequest,
|
||||
PluginResponse,
|
||||
PluginError,
|
||||
PluginReady,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Attaches IPC communication handler to an Express app
|
||||
* This allows the plugin's Express app to receive requests from sync-server via IPC
|
||||
*/
|
||||
export function attachPluginMiddleware(app: Express): void {
|
||||
if (!process.send) {
|
||||
console.warn('Not running as a forked process, plugin IPC will not work');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up IPC message handler
|
||||
process.on('message', async (message: PluginRequest) => {
|
||||
if (message.type !== 'request') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Simulate an HTTP request to the Express app
|
||||
await handleIPCRequest(app, message);
|
||||
} catch (error) {
|
||||
sendError(
|
||||
message.requestId,
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Send ready message to sync-server
|
||||
const readyMessage: PluginReady = { type: 'ready' };
|
||||
process.send(readyMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an IPC request by simulating an HTTP request to the Express app
|
||||
*/
|
||||
async function handleIPCRequest(
|
||||
app: Express,
|
||||
message: PluginRequest,
|
||||
): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
// Create mock request object
|
||||
const requestHeaders = message.headers as Record<string, string>;
|
||||
const mockReq = {
|
||||
method: message.method,
|
||||
path: message.path,
|
||||
url:
|
||||
message.path +
|
||||
(message.query && Object.keys(message.query).length > 0
|
||||
? '?' +
|
||||
new URLSearchParams(
|
||||
message.query as Record<string, string>,
|
||||
).toString()
|
||||
: ''),
|
||||
headers: requestHeaders,
|
||||
query: message.query,
|
||||
body: message.body,
|
||||
params: {},
|
||||
user: message.user, // Add user info for secrets access
|
||||
pluginSlug: message.pluginSlug, // Add plugin slug for namespaced secrets
|
||||
_body: true, // Mark body as already parsed to prevent body-parser from running
|
||||
get: function (name: string): string | undefined {
|
||||
return requestHeaders?.[name.toLowerCase()];
|
||||
},
|
||||
} as unknown as Request;
|
||||
|
||||
// Create mock response object
|
||||
let responseSent = false;
|
||||
const responseHeaders: Record<string, string | string[]> = {};
|
||||
let statusCode = 200;
|
||||
|
||||
const mockRes: Partial<Response> = {
|
||||
statusCode: 200,
|
||||
|
||||
status: function (code: number) {
|
||||
statusCode = code;
|
||||
this.statusCode = code;
|
||||
return this as Response;
|
||||
},
|
||||
|
||||
setHeader: function (name: string, value: string | string[]) {
|
||||
responseHeaders[name] = value;
|
||||
return this as Response;
|
||||
},
|
||||
|
||||
getHeader: function (
|
||||
name: string,
|
||||
): string | number | string[] | undefined {
|
||||
return responseHeaders[name];
|
||||
},
|
||||
|
||||
getHeaders: function () {
|
||||
return responseHeaders;
|
||||
},
|
||||
|
||||
send: function (body: unknown) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
sendResponse(message.requestId, statusCode, responseHeaders, body);
|
||||
resolve();
|
||||
}
|
||||
return this as Response;
|
||||
},
|
||||
|
||||
json: function (body: unknown) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
responseHeaders['Content-Type'] = 'application/json';
|
||||
sendResponse(message.requestId, statusCode, responseHeaders, body);
|
||||
resolve();
|
||||
}
|
||||
return this as Response;
|
||||
},
|
||||
|
||||
end: function (data?: unknown) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
sendResponse(message.requestId, statusCode, responseHeaders, data);
|
||||
resolve();
|
||||
}
|
||||
return this as Response;
|
||||
},
|
||||
};
|
||||
|
||||
// Use Express's internal router to handle the request
|
||||
try {
|
||||
// Call the app as a function - this is how Express handles requests
|
||||
(
|
||||
app as unknown as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (err?: unknown) => void,
|
||||
) => void
|
||||
)(mockReq, mockRes as Response, (err?: unknown) => {
|
||||
if (err && !responseSent) {
|
||||
responseSent = true;
|
||||
sendError(
|
||||
message.requestId,
|
||||
err instanceof Error ? err.message : 'Unknown error',
|
||||
);
|
||||
resolve();
|
||||
} else if (!responseSent) {
|
||||
// No route matched, send 404
|
||||
responseSent = true;
|
||||
sendResponse(
|
||||
message.requestId,
|
||||
404,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
{
|
||||
error: 'not_found',
|
||||
message: 'Route not found',
|
||||
},
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
sendError(
|
||||
message.requestId,
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response back to sync-server via IPC
|
||||
*/
|
||||
function sendResponse(
|
||||
requestId: string,
|
||||
status: number,
|
||||
headers: Record<string, string | string[]>,
|
||||
body: unknown,
|
||||
): void {
|
||||
if (!process.send) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response: PluginResponse = {
|
||||
type: 'response',
|
||||
requestId,
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
|
||||
process.send(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error back to sync-server via IPC
|
||||
*/
|
||||
function sendError(requestId: string, error: string): void {
|
||||
if (!process.send) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorResponse: PluginError = {
|
||||
type: 'error',
|
||||
requestId,
|
||||
error,
|
||||
};
|
||||
|
||||
process.send(errorResponse);
|
||||
}
|
||||
146
packages/plugins-core-sync-server/src/secrets.ts
Normal file
146
packages/plugins-core-sync-server/src/secrets.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Helper to send IPC message to parent (sync-server)
|
||||
*/
|
||||
function sendIPC(message: unknown): Promise<unknown> {
|
||||
if (!process.send) {
|
||||
throw new Error('Not running as a forked process');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = Math.random().toString(36).substring(7);
|
||||
|
||||
const handler = (response: {
|
||||
type: string;
|
||||
messageId: string;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}) => {
|
||||
if (
|
||||
response.type === 'secret-response' &&
|
||||
response.messageId === messageId
|
||||
) {
|
||||
process.off('message', handler);
|
||||
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
} else {
|
||||
resolve(response.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.on('message', handler);
|
||||
|
||||
const messageToSend = {
|
||||
...(typeof message === 'object' && message !== null ? message : {}),
|
||||
messageId,
|
||||
};
|
||||
process.send!(messageToSend);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a secret for the plugin
|
||||
* Secrets are namespaced by plugin slug automatically
|
||||
*/
|
||||
export async function saveSecret(
|
||||
req: Request,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const pluginSlug = (req as unknown as { pluginSlug?: string }).pluginSlug;
|
||||
|
||||
if (!pluginSlug) {
|
||||
return { success: false, error: 'Plugin slug not found' };
|
||||
}
|
||||
|
||||
const secretName = `${pluginSlug}_${key}`;
|
||||
|
||||
try {
|
||||
await sendIPC({
|
||||
type: 'secret-set',
|
||||
name: secretName,
|
||||
value,
|
||||
user: (req as unknown as { user?: unknown }).user,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a secret for the plugin
|
||||
* Secrets are namespaced by plugin slug automatically
|
||||
*/
|
||||
export async function getSecret(
|
||||
req: Request,
|
||||
key: string,
|
||||
): Promise<{ value?: string; error?: string }> {
|
||||
const pluginSlug = (req as unknown as { pluginSlug?: string }).pluginSlug;
|
||||
|
||||
if (!pluginSlug) {
|
||||
return { error: 'Plugin slug not found' };
|
||||
}
|
||||
|
||||
const secretName = `${pluginSlug}_${key}`;
|
||||
|
||||
try {
|
||||
const result = await sendIPC({
|
||||
type: 'secret-get',
|
||||
name: secretName,
|
||||
user: (req as unknown as { user?: unknown }).user,
|
||||
});
|
||||
|
||||
return { value: (result as { value?: string }).value };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return { value: undefined };
|
||||
}
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save multiple secrets at once
|
||||
*/
|
||||
export async function saveSecrets(
|
||||
req: Request,
|
||||
secrets: Record<string, string>,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
const result = await saveSecret(req, key, value);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple secrets at once
|
||||
*/
|
||||
export async function getSecrets(
|
||||
req: Request,
|
||||
keys: string[],
|
||||
): Promise<{ values?: Record<string, string | undefined>; error?: string }> {
|
||||
const values: Record<string, string | undefined> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
const result = await getSecret(req, key);
|
||||
if (result.error) {
|
||||
return { error: result.error };
|
||||
}
|
||||
values[key] = result.value;
|
||||
}
|
||||
|
||||
return { values };
|
||||
}
|
||||
146
packages/plugins-core-sync-server/src/types.ts
Normal file
146
packages/plugins-core-sync-server/src/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Plugin request data sent from sync-server to plugin via IPC
|
||||
*/
|
||||
export interface PluginRequest {
|
||||
type: 'request';
|
||||
requestId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
query: Record<string, string | string[] | undefined>;
|
||||
body: unknown;
|
||||
user?: UserInfo; // User information for secrets access
|
||||
pluginSlug?: string; // Plugin slug for namespaced secrets
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin response sent from plugin to sync-server via IPC
|
||||
*/
|
||||
export interface PluginResponse {
|
||||
type: 'response';
|
||||
requestId: string;
|
||||
status: number;
|
||||
headers?: Record<string, string | string[]>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin error response sent from plugin to sync-server via IPC
|
||||
*/
|
||||
export interface PluginError {
|
||||
type: 'error';
|
||||
requestId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin ready message sent from plugin to sync-server via IPC
|
||||
*/
|
||||
export interface PluginReady {
|
||||
type: 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* All possible IPC messages from plugin to sync-server
|
||||
*/
|
||||
export type PluginMessage = PluginResponse | PluginError | PluginReady;
|
||||
|
||||
/**
|
||||
* Authentication level required for a route
|
||||
*/
|
||||
export type AuthLevel = 'anonymous' | 'authenticated' | 'admin';
|
||||
|
||||
/**
|
||||
* Route configuration in manifest
|
||||
*/
|
||||
export interface PluginRoute {
|
||||
path: string;
|
||||
methods: string[];
|
||||
auth?: AuthLevel;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bank sync endpoint mapping
|
||||
*/
|
||||
export interface BankSyncEndpoints {
|
||||
status: string; // Route for checking connection status
|
||||
accounts: string; // Route for fetching accounts
|
||||
transactions: string; // Route for fetching transactions
|
||||
}
|
||||
|
||||
/**
|
||||
* Bank sync configuration in manifest
|
||||
*/
|
||||
export interface BankSyncConfig {
|
||||
enabled: boolean;
|
||||
displayName: string; // Display name for the bank sync provider
|
||||
endpoints: BankSyncEndpoints; // Mapping of standard endpoints to plugin routes
|
||||
description?: string;
|
||||
requiresAuth?: boolean; // Whether this bank sync requires authentication
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error codes for bank sync plugins
|
||||
*/
|
||||
export enum BankSyncErrorCode {
|
||||
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
|
||||
INVALID_ACCESS_TOKEN = 'INVALID_ACCESS_TOKEN',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
ACCOUNT_NOT_FOUND = 'ACCOUNT_NOT_FOUND',
|
||||
TRANSACTION_NOT_FOUND = 'TRANSACTION_NOT_FOUND',
|
||||
SERVER_ERROR = 'SERVER_ERROR',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
RATE_LIMIT = 'RATE_LIMIT',
|
||||
INVALID_REQUEST = 'INVALID_REQUEST',
|
||||
ACCOUNT_LOCKED = 'ACCOUNT_LOCKED',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error response for bank sync operations
|
||||
*/
|
||||
export interface BankSyncError {
|
||||
error_type: BankSyncErrorCode | string;
|
||||
error_code: BankSyncErrorCode | string;
|
||||
status: 'error' | 'rejected';
|
||||
reason: string; // Human-readable error message
|
||||
details?: Record<string, unknown>; // Optional provider-specific details
|
||||
}
|
||||
|
||||
/**
|
||||
* Manifest file structure for plugins
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
entry: string;
|
||||
author?: string;
|
||||
license?: string;
|
||||
routes?: PluginRoute[];
|
||||
bankSync?: BankSyncConfig; // Optional bank sync configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Express Request with plugin context
|
||||
*/
|
||||
export interface PluginExpressRequest extends Request {
|
||||
pluginSlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express Response type
|
||||
*/
|
||||
export type PluginExpressResponse = Response;
|
||||
|
||||
/**
|
||||
* User information extracted from request
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
role: 'user' | 'admin';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
30
packages/plugins-core-sync-server/tsconfig.json
Normal file
30
packages/plugins-core-sync-server/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"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"]
|
||||
}
|
||||
11
packages/sync-server-plugin-example/.gitignore
vendored
Normal file
11
packages/sync-server-plugin-example/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated build artifacts
|
||||
manifest.json
|
||||
*.zip
|
||||
|
||||
Binary file not shown.
31318
packages/sync-server-plugin-example/dist/bundle.js
vendored
Normal file
31318
packages/sync-server-plugin-example/dist/bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
211
packages/sync-server-plugin-example/dist/index.js
vendored
Normal file
211
packages/sync-server-plugin-example/dist/index.js
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
import { attachPluginMiddleware } from '@actual-app/plugins-core-sync-server';
|
||||
import express from 'express';
|
||||
// The manifest is imported but not used in runtime code
|
||||
// It's only used during build time to generate the JSON manifest
|
||||
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);
|
||||
// Example routes
|
||||
/**
|
||||
* GET /hello
|
||||
* Simple hello world endpoint
|
||||
*/
|
||||
app.get('/hello', (_req, res) => {
|
||||
res.json({
|
||||
message: 'Hello from example plugin!',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
/**
|
||||
* GET /info
|
||||
* Get plugin information
|
||||
*/
|
||||
app.get('/info', (_req, res) => {
|
||||
res.json({
|
||||
name: 'example-plugin',
|
||||
version: '0.0.1',
|
||||
description: 'An example plugin for Actual sync-server',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/hello',
|
||||
auth: 'anonymous',
|
||||
description: 'Simple hello world',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/info',
|
||||
auth: 'anonymous',
|
||||
description: 'Plugin information',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/status',
|
||||
auth: 'anonymous',
|
||||
description: 'Health check',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/echo',
|
||||
auth: 'authenticated',
|
||||
description: 'Echo back the request body (requires auth)',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/data/:id',
|
||||
auth: 'authenticated',
|
||||
description: 'Get data by ID (requires auth)',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/calculate',
|
||||
auth: 'authenticated',
|
||||
description: 'Perform calculations (requires auth)',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/admin/settings',
|
||||
auth: 'admin',
|
||||
description: 'View admin settings (admin only)',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/admin/settings',
|
||||
auth: 'admin',
|
||||
description: 'Update admin settings (admin only)',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
/**
|
||||
* POST /echo
|
||||
* Echo back the request body
|
||||
*/
|
||||
app.post('/echo', (req, res) => {
|
||||
res.json({
|
||||
received: req.body,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
});
|
||||
});
|
||||
/**
|
||||
* GET /data/:id
|
||||
* Example route with parameters
|
||||
*/
|
||||
app.get('/data/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
res.json({
|
||||
id,
|
||||
data: {
|
||||
message: `Data for ID: ${id}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
/**
|
||||
* GET /status
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/status', (_req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
});
|
||||
});
|
||||
/**
|
||||
* POST /calculate
|
||||
* Example calculation endpoint
|
||||
*/
|
||||
app.post('/calculate', (req, res) => {
|
||||
const { operation, a, b } = req.body;
|
||||
if (typeof a !== 'number' || typeof b !== 'number') {
|
||||
res.status(400).json({
|
||||
error: 'invalid_input',
|
||||
message: 'Both a and b must be numbers',
|
||||
});
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
result = a + b;
|
||||
break;
|
||||
case 'subtract':
|
||||
result = a - b;
|
||||
break;
|
||||
case 'multiply':
|
||||
result = a * b;
|
||||
break;
|
||||
case 'divide':
|
||||
if (b === 0) {
|
||||
res.status(400).json({
|
||||
error: 'division_by_zero',
|
||||
message: 'Cannot divide by zero',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = a / b;
|
||||
break;
|
||||
default:
|
||||
res.status(400).json({
|
||||
error: 'invalid_operation',
|
||||
message: 'Operation must be one of: add, subtract, multiply, divide',
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
operation,
|
||||
a,
|
||||
b,
|
||||
result,
|
||||
});
|
||||
});
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Route not found',
|
||||
path: req.path,
|
||||
});
|
||||
});
|
||||
/**
|
||||
* GET /admin/settings
|
||||
* Admin-only endpoint (requires admin role)
|
||||
*/
|
||||
app.get('/admin/settings', (_req, res) => {
|
||||
res.json({
|
||||
message: 'Admin settings accessed',
|
||||
settings: {
|
||||
pluginEnabled: true,
|
||||
maxRequests: 1000,
|
||||
logLevel: 'info',
|
||||
},
|
||||
});
|
||||
});
|
||||
/**
|
||||
* POST /admin/settings
|
||||
* Update admin settings (requires admin role)
|
||||
*/
|
||||
app.post('/admin/settings', (req, res) => {
|
||||
const updates = req.body;
|
||||
res.json({
|
||||
message: 'Settings updated successfully',
|
||||
updates,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
// Error handler
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: 'An internal error occurred',
|
||||
});
|
||||
});
|
||||
console.log('Example plugin initialized and ready');
|
||||
55
packages/sync-server-plugin-example/dist/manifest.js
vendored
Normal file
55
packages/sync-server-plugin-example/dist/manifest.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
export const manifest = {
|
||||
name: 'example-plugin',
|
||||
version: '0.0.1',
|
||||
description:
|
||||
'An example plugin for Actual sync-server demonstrating plugin capabilities',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/hello',
|
||||
methods: ['GET'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public hello world endpoint',
|
||||
},
|
||||
{
|
||||
path: '/info',
|
||||
methods: ['GET'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public plugin information',
|
||||
},
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['GET'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public health check',
|
||||
},
|
||||
{
|
||||
path: '/echo',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Echo endpoint - requires authentication',
|
||||
},
|
||||
{
|
||||
path: '/data/:id',
|
||||
methods: ['GET'],
|
||||
auth: 'authenticated',
|
||||
description: 'Get data by ID - requires authentication',
|
||||
},
|
||||
{
|
||||
path: '/calculate',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Calculate endpoint - requires authentication',
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
methods: ['GET', 'POST'],
|
||||
auth: 'admin',
|
||||
description: 'Admin settings - requires admin role',
|
||||
},
|
||||
],
|
||||
};
|
||||
// Export for use in tests or other files
|
||||
export default manifest;
|
||||
52
packages/sync-server-plugin-example/manifest.json
Normal file
52
packages/sync-server-plugin-example/manifest.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "example-plugin",
|
||||
"version": "0.0.1",
|
||||
"description": "An example plugin for Actual sync-server demonstrating plugin capabilities",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/hello",
|
||||
"methods": ["GET"],
|
||||
"auth": "anonymous",
|
||||
"description": "Public hello world endpoint"
|
||||
},
|
||||
{
|
||||
"path": "/info",
|
||||
"methods": ["GET"],
|
||||
"auth": "anonymous",
|
||||
"description": "Public plugin information"
|
||||
},
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": ["GET"],
|
||||
"auth": "anonymous",
|
||||
"description": "Public health check"
|
||||
},
|
||||
{
|
||||
"path": "/echo",
|
||||
"methods": ["POST"],
|
||||
"auth": "authenticated",
|
||||
"description": "Echo endpoint - requires authentication"
|
||||
},
|
||||
{
|
||||
"path": "/data/:id",
|
||||
"methods": ["GET"],
|
||||
"auth": "authenticated",
|
||||
"description": "Get data by ID - requires authentication"
|
||||
},
|
||||
{
|
||||
"path": "/calculate",
|
||||
"methods": ["POST"],
|
||||
"auth": "authenticated",
|
||||
"description": "Calculate endpoint - requires authentication"
|
||||
},
|
||||
{
|
||||
"path": "/admin/settings",
|
||||
"methods": ["GET", "POST"],
|
||||
"auth": "admin",
|
||||
"description": "Admin settings - requires admin role"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/sync-server-plugin-example/package.json
Normal file
36
packages/sync-server-plugin-example/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@actual-app/sync-server-plugin-example",
|
||||
"version": "0.0.1",
|
||||
"description": "Example plugin for Actual sync-server",
|
||||
"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",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"sync-server",
|
||||
"example"
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
39
packages/sync-server-plugin-example/scripts/build-bundle.cjs
Normal file
39
packages/sync-server-plugin-example/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();
|
||||
55
packages/sync-server-plugin-example/scripts/build-manifest.cjs
Executable file
55
packages/sync-server-plugin-example/scripts/build-manifest.cjs
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/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}`);
|
||||
|
||||
if (manifest.routes && manifest.routes.length > 0) {
|
||||
console.log(`🛣️ Routes: ${manifest.routes.length} defined`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
89
packages/sync-server-plugin-example/scripts/build-zip.cjs
Executable file
89
packages/sync-server-plugin-example/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();
|
||||
235
packages/sync-server-plugin-example/src/index.ts
Normal file
235
packages/sync-server-plugin-example/src/index.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { attachPluginMiddleware } from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
|
||||
// The manifest is imported but not used in runtime code
|
||||
// It's only used during build time to generate the JSON manifest
|
||||
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);
|
||||
|
||||
// Example routes
|
||||
|
||||
/**
|
||||
* GET /hello
|
||||
* Simple hello world endpoint
|
||||
*/
|
||||
app.get('/hello', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
message: 'Hello from example plugin!',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /info
|
||||
* Get plugin information
|
||||
*/
|
||||
app.get('/info', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
name: 'example-plugin',
|
||||
version: '0.0.1',
|
||||
description: 'An example plugin for Actual sync-server',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/hello',
|
||||
auth: 'anonymous',
|
||||
description: 'Simple hello world',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/info',
|
||||
auth: 'anonymous',
|
||||
description: 'Plugin information',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/status',
|
||||
auth: 'anonymous',
|
||||
description: 'Health check',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/echo',
|
||||
auth: 'authenticated',
|
||||
description: 'Echo back the request body (requires auth)',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/data/:id',
|
||||
auth: 'authenticated',
|
||||
description: 'Get data by ID (requires auth)',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/calculate',
|
||||
auth: 'authenticated',
|
||||
description: 'Perform calculations (requires auth)',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/admin/settings',
|
||||
auth: 'admin',
|
||||
description: 'View admin settings (admin only)',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/admin/settings',
|
||||
auth: 'admin',
|
||||
description: 'Update admin settings (admin only)',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /echo
|
||||
* Echo back the request body
|
||||
*/
|
||||
app.post('/echo', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
received: req.body,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /data/:id
|
||||
* Example route with parameters
|
||||
*/
|
||||
app.get('/data/:id', (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
res.json({
|
||||
id,
|
||||
data: {
|
||||
message: `Data for ID: ${id}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/status', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /calculate
|
||||
* Example calculation endpoint
|
||||
*/
|
||||
app.post('/calculate', (req: Request, res: Response) => {
|
||||
const { operation, a, b } = req.body;
|
||||
|
||||
if (typeof a !== 'number' || typeof b !== 'number') {
|
||||
res.status(400).json({
|
||||
error: 'invalid_input',
|
||||
message: 'Both a and b must be numbers',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result: number;
|
||||
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
result = a + b;
|
||||
break;
|
||||
case 'subtract':
|
||||
result = a - b;
|
||||
break;
|
||||
case 'multiply':
|
||||
result = a * b;
|
||||
break;
|
||||
case 'divide':
|
||||
if (b === 0) {
|
||||
res.status(400).json({
|
||||
error: 'division_by_zero',
|
||||
message: 'Cannot divide by zero',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = a / b;
|
||||
break;
|
||||
default:
|
||||
res.status(400).json({
|
||||
error: 'invalid_operation',
|
||||
message: 'Operation must be one of: add, subtract, multiply, divide',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
operation,
|
||||
a,
|
||||
b,
|
||||
result,
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Route not found',
|
||||
path: req.path,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/settings
|
||||
* Admin-only endpoint (requires admin role)
|
||||
*/
|
||||
app.get('/admin/settings', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
message: 'Admin settings accessed',
|
||||
settings: {
|
||||
pluginEnabled: true,
|
||||
maxRequests: 1000,
|
||||
logLevel: 'info',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/settings
|
||||
* Update admin settings (requires admin role)
|
||||
*/
|
||||
app.post('/admin/settings', (req: Request, res: Response) => {
|
||||
const updates = req.body;
|
||||
|
||||
res.json({
|
||||
message: 'Settings updated successfully',
|
||||
updates,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
|
||||
app.use(
|
||||
(err: Error, _req: Request, res: Response, _next: express.NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: 'An internal error occurred',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
console.log('Example plugin initialized and ready');
|
||||
58
packages/sync-server-plugin-example/src/manifest.ts
Normal file
58
packages/sync-server-plugin-example/src/manifest.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'example-plugin',
|
||||
version: '0.0.1',
|
||||
description:
|
||||
'An example plugin for Actual sync-server demonstrating plugin capabilities',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/hello',
|
||||
methods: ['GET'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public hello world endpoint',
|
||||
},
|
||||
{
|
||||
path: '/info',
|
||||
methods: ['GET'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public plugin information',
|
||||
},
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['GET'],
|
||||
auth: 'anonymous',
|
||||
description: 'Public health check',
|
||||
},
|
||||
{
|
||||
path: '/echo',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Echo endpoint - requires authentication',
|
||||
},
|
||||
{
|
||||
path: '/data/:id',
|
||||
methods: ['GET'],
|
||||
auth: 'authenticated',
|
||||
description: 'Get data by ID - requires authentication',
|
||||
},
|
||||
{
|
||||
path: '/calculate',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Calculate endpoint - requires authentication',
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
methods: ['GET', 'POST'],
|
||||
auth: 'admin',
|
||||
description: 'Admin settings - requires admin role',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Export for use in tests or other files
|
||||
export default manifest;
|
||||
28
packages/sync-server-plugin-example/tsconfig.json
Normal file
28
packages/sync-server-plugin-example/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"]
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "2.1.0",
|
||||
"@actual-app/web": "workspace:*",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"convict": "^6.2.4",
|
||||
|
||||
175
packages/sync-server/src/app-plugins.js
Normal file
175
packages/sync-server/src/app-plugins.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import path from 'path';
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import { config } from './load-config.js';
|
||||
import { PluginManager } from './plugin-manager.js';
|
||||
import { createPluginMiddleware } from './plugin-middleware.js';
|
||||
import {
|
||||
errorMiddleware,
|
||||
requestLoggerMiddleware,
|
||||
} from './util/middlewares.js';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(requestLoggerMiddleware);
|
||||
|
||||
export { app as handlers };
|
||||
|
||||
// Initialize plugin manager
|
||||
const pluginsDir = path.join(config.get('serverFiles'), 'plugins');
|
||||
const pluginManager = new PluginManager(pluginsDir);
|
||||
|
||||
app.get('/bank-sync/list', (_req, res) => {
|
||||
try {
|
||||
const bankSyncPlugins = pluginManager.getBankSyncPlugins();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
providers: bankSyncPlugins,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/bank-sync/:providerSlug/status', async (req, res) => {
|
||||
try {
|
||||
const { providerSlug } = req.params;
|
||||
|
||||
// Get the plugin
|
||||
const plugin = pluginManager.getPlugin(providerSlug);
|
||||
if (!plugin || !plugin.manifest?.bankSync?.enabled) {
|
||||
return res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the plugin defines a status endpoint
|
||||
const statusEndpoint = plugin.manifest.bankSync.endpoints?.status;
|
||||
if (!statusEndpoint) {
|
||||
// Plugin exists but doesn't have a status endpoint - consider it configured
|
||||
return res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to the actual plugin endpoint using absolute URL
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
const redirectUrl = `${protocol}://${host}/plugins-api/${providerSlug}${statusEndpoint}`;
|
||||
return res.redirect(307, redirectUrl);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add endpoint to get accounts from a specific bank sync plugin
|
||||
app.post('/bank-sync/:providerSlug/accounts', async (req, res) => {
|
||||
try {
|
||||
const { providerSlug } = req.params;
|
||||
|
||||
// Get the plugin
|
||||
const plugin = pluginManager.getPlugin(providerSlug);
|
||||
if (!plugin || !plugin.manifest?.bankSync?.enabled) {
|
||||
return res.status(404).json({
|
||||
error_code: 'PLUGIN_NOT_FOUND',
|
||||
reason: `Bank sync plugin '${providerSlug}' not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the accounts endpoint from the plugin manifest
|
||||
const accountsEndpoint = plugin.manifest.bankSync.endpoints?.accounts;
|
||||
if (!accountsEndpoint) {
|
||||
return res.status(500).json({
|
||||
error_code: 'ENDPOINT_NOT_FOUND',
|
||||
reason: `Plugin '${providerSlug}' does not define an accounts endpoint`,
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to the actual plugin endpoint using absolute URL
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
const redirectUrl = `${protocol}://${host}/plugins-api/${providerSlug}${accountsEndpoint}`;
|
||||
return res.redirect(307, redirectUrl);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error_code: 'INTERNAL_ERROR',
|
||||
reason: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add endpoint to get transactions from a specific bank sync plugin
|
||||
app.post('/bank-sync/:providerSlug/transactions', async (req, res) => {
|
||||
try {
|
||||
const { providerSlug } = req.params;
|
||||
|
||||
// Get the plugin
|
||||
const plugin = pluginManager.getPlugin(providerSlug);
|
||||
if (!plugin || !plugin.manifest?.bankSync?.enabled) {
|
||||
return res.status(404).json({
|
||||
error_code: 'PLUGIN_NOT_FOUND',
|
||||
reason: `Bank sync plugin '${providerSlug}' not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the transactions endpoint from the plugin manifest
|
||||
const transactionsEndpoint =
|
||||
plugin.manifest.bankSync.endpoints?.transactions;
|
||||
if (!transactionsEndpoint) {
|
||||
return res.status(500).json({
|
||||
error_code: 'ENDPOINT_NOT_FOUND',
|
||||
reason: `Plugin '${providerSlug}' does not define a transactions endpoint`,
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to the actual plugin endpoint using absolute URL
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
const redirectUrl = `${protocol}://${host}/plugins-api/${providerSlug}${transactionsEndpoint}`;
|
||||
return res.redirect(307, redirectUrl);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error_code: 'INTERNAL_ERROR',
|
||||
reason: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add plugin middleware to handle all plugin routes
|
||||
app.use(createPluginMiddleware(pluginManager));
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorMiddleware);
|
||||
|
||||
// Load plugins on startup
|
||||
async function loadPlugins() {
|
||||
try {
|
||||
await pluginManager.loadPlugins();
|
||||
const loadedPlugins = pluginManager.getOnlinePlugins();
|
||||
console.log(`Loaded ${loadedPlugins.length} plugin(s):`, loadedPlugins);
|
||||
} catch (error) {
|
||||
console.error('Error loading plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start loading plugins
|
||||
loadPlugins();
|
||||
|
||||
// Export plugin manager for potential external use
|
||||
export { pluginManager };
|
||||
@@ -28,7 +28,7 @@ import { getPathForUserFile, getPathForGroupFile } from './util/paths.js';
|
||||
|
||||
const app = express();
|
||||
app.use(validateSessionMiddleware);
|
||||
app.use(errorMiddleware);
|
||||
// @ts-expect-error - express-winston types don't perfectly align with Express types
|
||||
app.use(requestLoggerMiddleware);
|
||||
app.use(
|
||||
express.raw({
|
||||
@@ -403,3 +403,6 @@ app.post('/delete-user-file', (req, res) => {
|
||||
|
||||
res.send(OK_RESPONSE);
|
||||
});
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorMiddleware);
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as corsApp from './app-cors-proxy.js';
|
||||
import * as goCardlessApp from './app-gocardless/app-gocardless.js';
|
||||
import * as openidApp from './app-openid.js';
|
||||
import * as pluggai from './app-pluggyai/app-pluggyai.js';
|
||||
import * as pluginsApp from './app-plugins.js';
|
||||
import * as secretApp from './app-secrets.js';
|
||||
import * as simpleFinApp from './app-simplefin/app-simplefin.js';
|
||||
import * as syncApp from './app-sync.js';
|
||||
@@ -67,6 +68,7 @@ if (config.get('corsProxy.enabled')) {
|
||||
|
||||
app.use('/admin', adminApp.handlers);
|
||||
app.use('/openid', openidApp.handlers);
|
||||
app.use('/plugins-api', pluginsApp.handlers);
|
||||
|
||||
app.get('/mode', (req, res) => {
|
||||
res.send(config.get('mode'));
|
||||
@@ -193,6 +195,7 @@ export async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof app.listen>;
|
||||
if (config.get('https.key') && config.get('https.cert')) {
|
||||
const https = await import('node:https');
|
||||
const httpsOptions = {
|
||||
@@ -200,12 +203,40 @@ export async function run() {
|
||||
key: parseHTTPSConfig(config.get('https.key')),
|
||||
cert: parseHTTPSConfig(config.get('https.cert')),
|
||||
};
|
||||
https.createServer(httpsOptions, app).listen(port, hostname, () => {
|
||||
sendServerStartedMessage();
|
||||
});
|
||||
server = https
|
||||
.createServer(httpsOptions, app)
|
||||
.listen(port, hostname, () => {
|
||||
sendServerStartedMessage();
|
||||
});
|
||||
} else {
|
||||
app.listen(port, hostname, () => {
|
||||
server = app.listen(port, hostname, () => {
|
||||
sendServerStartedMessage();
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`${signal} received. Starting graceful shutdown...`);
|
||||
|
||||
// Close HTTP server (stop accepting new connections)
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
});
|
||||
|
||||
// Shutdown plugins
|
||||
try {
|
||||
const { pluginManager } = await import('./app-plugins.js');
|
||||
await pluginManager.shutdown();
|
||||
console.log('Plugins shut down successfully');
|
||||
} catch (error) {
|
||||
console.error('Error shutting down plugins:', error);
|
||||
}
|
||||
|
||||
// Exit process
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Register shutdown handlers
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
}
|
||||
|
||||
194
packages/sync-server/src/auth-checker.js
Normal file
194
packages/sync-server/src/auth-checker.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Authentication and Authorization Checker
|
||||
*
|
||||
* This module provides utilities to extract user information from requests
|
||||
* and check if users have the required permissions for plugin routes.
|
||||
*/
|
||||
|
||||
import { getSession, getUserInfo, isAdmin } from './account-db.js';
|
||||
|
||||
const TOKEN_EXPIRATION_NEVER = -1;
|
||||
const MS_PER_SECOND = 1000;
|
||||
|
||||
/**
|
||||
* Extract user information from request headers
|
||||
*
|
||||
* This validates the session token (GUID) by:
|
||||
* 1. Looking up the session in the database
|
||||
* 2. Getting the user_id from the session
|
||||
* 3. Looking up the user to get their permissions
|
||||
* 4. Checking if user is admin
|
||||
*
|
||||
* @param {Object} headers - Request headers
|
||||
* @returns {Promise<Object|null>} User info object or null if not authenticated
|
||||
*/
|
||||
async function extractUserFromHeaders(headers) {
|
||||
// Get token from headers (case-insensitive)
|
||||
const token = headers['x-actual-token'] || headers['X-ACTUAL-TOKEN'];
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Look up the session by token (GUID)
|
||||
const session = getSession(token);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Check if session is expired
|
||||
if (
|
||||
session.expires_at !== TOKEN_EXPIRATION_NEVER &&
|
||||
session.expires_at * MS_PER_SECOND <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 3: Get user_id from session
|
||||
const userId = session.user_id;
|
||||
|
||||
// Step 4: Look up user by user_id
|
||||
const user = getUserInfo(userId);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 5: Determine user role using the isAdmin function
|
||||
const role = isAdmin(userId) ? 'admin' : 'user';
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
role,
|
||||
username: user.user_name,
|
||||
displayName: user.display_name,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user meets the authentication requirements for a route
|
||||
*
|
||||
* @param {Object|null} user - User information object
|
||||
* @param {string} authLevel - Required auth level: 'anonymous', 'authenticated', 'admin'
|
||||
* @returns {Object} Result with allowed flag and error details
|
||||
*/
|
||||
function checkAuth(user, authLevel = 'authenticated') {
|
||||
// Anonymous routes are always allowed
|
||||
if (authLevel === 'anonymous') {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Authenticated routes require a user to be logged in
|
||||
if (authLevel === 'authenticated') {
|
||||
if (!user) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 401,
|
||||
error: 'unauthorized',
|
||||
message: 'Authentication required',
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Admin routes require user to be logged in AND have admin role
|
||||
if (authLevel === 'admin') {
|
||||
if (!user) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 401,
|
||||
error: 'unauthorized',
|
||||
message: 'Authentication required',
|
||||
};
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
error: 'forbidden',
|
||||
message: 'Admin privileges required',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Unknown auth level - default to requiring authentication
|
||||
console.warn(
|
||||
`Unknown auth level: ${authLevel}, defaulting to 'authenticated'`,
|
||||
);
|
||||
return checkAuth(user, 'authenticated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching route configuration from manifest
|
||||
*
|
||||
* @param {Array} routes - Array of route configurations from manifest
|
||||
* @param {string} method - HTTP method (GET, POST, etc.)
|
||||
* @param {string} path - Request path
|
||||
* @returns {Object|null} Matching route config or null
|
||||
*/
|
||||
function findMatchingRoute(routes, method, path) {
|
||||
if (!routes || routes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
for (const route of routes) {
|
||||
if (route.methods.includes(method) && route.path === path) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
// Try pattern matching (for routes with parameters like /user/:id)
|
||||
for (const route of routes) {
|
||||
if (route.methods.includes(method)) {
|
||||
const pattern = route.path.replace(/:[^/]+/g, '[^/]+');
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
if (regex.test(path)) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication level for a route
|
||||
*
|
||||
* @param {Object} manifest - Plugin manifest
|
||||
* @param {string} method - HTTP method
|
||||
* @param {string} path - Request path
|
||||
* @returns {string} Auth level: 'anonymous', 'authenticated', or 'admin'
|
||||
*/
|
||||
function getRouteAuthLevel(manifest, method, path) {
|
||||
if (!manifest.routes || manifest.routes.length === 0) {
|
||||
// No routes defined - default to 'authenticated'
|
||||
return 'authenticated';
|
||||
}
|
||||
|
||||
const matchingRoute = findMatchingRoute(manifest.routes, method, path);
|
||||
|
||||
if (matchingRoute) {
|
||||
// Use the route's auth level, or default to 'authenticated'
|
||||
return matchingRoute.auth || 'authenticated';
|
||||
}
|
||||
|
||||
// Route not found in manifest - default to 'authenticated'
|
||||
return 'authenticated';
|
||||
}
|
||||
|
||||
export {
|
||||
extractUserFromHeaders,
|
||||
checkAuth,
|
||||
getRouteAuthLevel,
|
||||
findMatchingRoute,
|
||||
};
|
||||
535
packages/sync-server/src/plugin-manager.js
Normal file
535
packages/sync-server/src/plugin-manager.js
Normal file
@@ -0,0 +1,535 @@
|
||||
import { fork, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import AdmZip from 'adm-zip';
|
||||
import createDebug from 'debug';
|
||||
|
||||
import { secretsService } from './services/secrets-service.js';
|
||||
|
||||
const debug = createDebug('actual:config');
|
||||
|
||||
class PluginManager {
|
||||
constructor(pluginsDir) {
|
||||
this.pluginsDir = pluginsDir;
|
||||
this.onlinePlugins = new Map();
|
||||
this.extractedPlugins = new Map(); // Track extracted zip plugins for cleanup
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a zip file to a temporary directory
|
||||
*/
|
||||
extractZipPlugin(zipPath, pluginSlug) {
|
||||
try {
|
||||
const zip = new AdmZip(zipPath);
|
||||
const extractPath = path.join(os.tmpdir(), 'actual-plugins', pluginSlug);
|
||||
|
||||
// Clean up existing extraction if it exists
|
||||
if (fs.existsSync(extractPath)) {
|
||||
fs.rmSync(extractPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Extract the zip
|
||||
zip.extractAllTo(extractPath, true);
|
||||
|
||||
// If plugin has package.json with dependencies, install them
|
||||
const packageJsonPath = path.join(extractPath, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(packageJsonPath, 'utf8'),
|
||||
);
|
||||
if (packageJson.dependencies && Object.keys(packageJson.dependencies).length > 0) {
|
||||
console.log(`Installing dependencies for plugin ${pluginSlug}...`);
|
||||
execSync('npm install --production --no-audit --no-fund', {
|
||||
cwd: extractPath,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
console.log(`Dependencies installed for plugin ${pluginSlug}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to install dependencies for plugin ${pluginSlug}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Track this for cleanup
|
||||
this.extractedPlugins.set(pluginSlug, extractPath);
|
||||
|
||||
return extractPath;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to extract zip plugin ${pluginSlug}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin slug from manifest
|
||||
*/
|
||||
getPluginSlugFromManifest(pluginPath) {
|
||||
const manifestPath = path.join(pluginPath, 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
return manifest.name || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all plugins from the plugins directory
|
||||
* Supports both subdirectories and .zip files
|
||||
* On slug clash, loads the first plugin and warns about duplicates
|
||||
*/
|
||||
async loadPlugins() {
|
||||
if (!fs.existsSync(this.pluginsDir)) {
|
||||
console.log('Plugins directory does not exist:', this.pluginsDir);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(this.pluginsDir, { withFileTypes: true });
|
||||
const loadedSlugs = new Set();
|
||||
const pluginsToLoad = [];
|
||||
|
||||
// First pass: collect all plugins and their slugs
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
if (entry.isDirectory()) {
|
||||
const pluginPath = path.join(this.pluginsDir, entry.name);
|
||||
const pluginSlug =
|
||||
this.getPluginSlugFromManifest(pluginPath) || entry.name;
|
||||
pluginsToLoad.push({
|
||||
type: 'directory',
|
||||
name: entry.name,
|
||||
slug: pluginSlug,
|
||||
path: pluginPath,
|
||||
});
|
||||
} else if (entry.isFile() && entry.name.endsWith('.zip')) {
|
||||
const zipPath = path.join(this.pluginsDir, entry.name);
|
||||
const zipFilename = entry.name;
|
||||
|
||||
// Try to extract to temp location to read manifest
|
||||
const tempSlug = zipFilename
|
||||
.replace(/\.zip$/, '')
|
||||
.replace(/\.\d+\.\d+\.\d+$/, ''); // Remove semver if present
|
||||
|
||||
try {
|
||||
const extractedPath = this.extractZipPlugin(zipPath, tempSlug);
|
||||
const pluginSlug =
|
||||
this.getPluginSlugFromManifest(extractedPath) || tempSlug;
|
||||
|
||||
pluginsToLoad.push({
|
||||
type: 'zip',
|
||||
name: entry.name,
|
||||
slug: pluginSlug,
|
||||
path: extractedPath,
|
||||
zipPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to extract zip ${entry.name}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to process plugin ${entry.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: load plugins, checking for slug clashes
|
||||
for (const plugin of pluginsToLoad) {
|
||||
try {
|
||||
if (loadedSlugs.has(plugin.slug)) {
|
||||
console.warn(
|
||||
`⚠️ Plugin slug clash detected: "${plugin.slug}" from "${plugin.name}" ` +
|
||||
`is already loaded. Skipping this plugin.`,
|
||||
);
|
||||
|
||||
// Clean up extracted zip if it wasn't loaded
|
||||
if (plugin.type === 'zip' && this.extractedPlugins.has(plugin.slug)) {
|
||||
const extractPath = this.extractedPlugins.get(plugin.slug);
|
||||
if (fs.existsSync(extractPath)) {
|
||||
fs.rmSync(extractPath, { recursive: true, force: true });
|
||||
}
|
||||
this.extractedPlugins.delete(plugin.slug);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.loadPlugin(plugin.slug, plugin.path, plugin.type === 'zip');
|
||||
loadedSlugs.add(plugin.slug);
|
||||
console.log(`✅ Loaded plugin: ${plugin.slug} (from ${plugin.name})`);
|
||||
|
||||
// Debug: Show plugin routes and permissions
|
||||
this.debugPluginRoutes(plugin.slug);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load plugin ${plugin.name}:`, error.message);
|
||||
|
||||
// Clean up extracted zip on failure
|
||||
if (plugin.type === 'zip' && this.extractedPlugins.has(plugin.slug)) {
|
||||
const extractPath = this.extractedPlugins.get(plugin.slug);
|
||||
if (fs.existsSync(extractPath)) {
|
||||
fs.rmSync(extractPath, { recursive: true, force: true });
|
||||
}
|
||||
this.extractedPlugins.delete(plugin.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single plugin by slug
|
||||
* @param {string} pluginSlug - The plugin identifier
|
||||
* @param {string} pluginPath - Path to the plugin directory
|
||||
* @param {boolean} _isExtracted - Whether this plugin was extracted from a zip
|
||||
*/
|
||||
async loadPlugin(pluginSlug, pluginPath, _isExtracted = false) {
|
||||
const manifestPath = path.join(pluginPath, 'manifest.json');
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Plugin ${pluginSlug} does not have a manifest.json`);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
if (!manifest.entry) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginSlug} manifest does not specify an entry point`,
|
||||
);
|
||||
}
|
||||
|
||||
const entryPath = path.join(pluginPath, manifest.entry);
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginSlug} entry point does not exist: ${entryPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fork the plugin as a child process
|
||||
const childProcess = fork(entryPath, {
|
||||
cwd: pluginPath,
|
||||
silent: false,
|
||||
env: {
|
||||
...process.env,
|
||||
PLUGIN_SLUG: pluginSlug,
|
||||
PLUGIN_PATH: pluginPath,
|
||||
},
|
||||
});
|
||||
|
||||
// Store plugin information
|
||||
this.onlinePlugins.set(pluginSlug, {
|
||||
slug: pluginSlug,
|
||||
manifest,
|
||||
process: childProcess,
|
||||
ready: false,
|
||||
pendingRequests: new Map(),
|
||||
});
|
||||
|
||||
// Setup IPC message handler
|
||||
childProcess.on('message', message => {
|
||||
this.handlePluginMessage(pluginSlug, message);
|
||||
});
|
||||
|
||||
childProcess.on('error', error => {
|
||||
console.error(`Plugin ${pluginSlug} error:`, error);
|
||||
});
|
||||
|
||||
childProcess.on('exit', code => {
|
||||
console.log(`Plugin ${pluginSlug} exited with code ${code}`);
|
||||
this.onlinePlugins.delete(pluginSlug);
|
||||
});
|
||||
|
||||
// Wait for plugin to be ready
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`Plugin ${pluginSlug} did not respond within timeout`),
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
const readyHandler = message => {
|
||||
if (message.type === 'ready') {
|
||||
clearTimeout(timeout);
|
||||
const plugin = this.onlinePlugins.get(pluginSlug);
|
||||
if (plugin) {
|
||||
plugin.ready = true;
|
||||
}
|
||||
childProcess.removeListener('message', readyHandler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
childProcess.on('message', readyHandler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from plugin processes
|
||||
*/
|
||||
handlePluginMessage(pluginSlug, message) {
|
||||
const plugin = this.onlinePlugins.get(pluginSlug);
|
||||
if (!plugin) return;
|
||||
|
||||
if (message.type === 'response') {
|
||||
const { requestId, status, headers, body } = message;
|
||||
const pendingRequest = plugin.pendingRequests.get(requestId);
|
||||
|
||||
if (pendingRequest) {
|
||||
pendingRequest.resolve({ status, headers, body });
|
||||
plugin.pendingRequests.delete(requestId);
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
const { requestId, error } = message;
|
||||
const pendingRequest = plugin.pendingRequests.get(requestId);
|
||||
|
||||
if (pendingRequest) {
|
||||
pendingRequest.reject(new Error(error));
|
||||
plugin.pendingRequests.delete(requestId);
|
||||
}
|
||||
} else if (message.type === 'secret-set' || message.type === 'secret-get') {
|
||||
// Handle secret operations from plugin
|
||||
this.handleSecretOperation(pluginSlug, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle secret operations from plugins
|
||||
* Uses the sync-server's existing secrets service for proper persistence
|
||||
*/
|
||||
async handleSecretOperation(pluginSlug, message) {
|
||||
const plugin = this.onlinePlugins.get(pluginSlug);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messageId, type, name, value } = message;
|
||||
|
||||
try {
|
||||
if (type === 'secret-set') {
|
||||
// Use the secrets service for proper persistence
|
||||
secretsService.set(name, value);
|
||||
|
||||
plugin.process.send({
|
||||
type: 'secret-response',
|
||||
messageId,
|
||||
data: { success: true },
|
||||
});
|
||||
} else if (type === 'secret-get') {
|
||||
// Get secret from the secrets service
|
||||
const exists = secretsService.exists(name);
|
||||
const secretValue = exists ? secretsService.get(name) : undefined;
|
||||
|
||||
plugin.process.send({
|
||||
type: 'secret-response',
|
||||
messageId,
|
||||
data: { value: secretValue },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
plugin.process.send({
|
||||
type: 'secret-response',
|
||||
messageId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to a plugin
|
||||
*/
|
||||
async sendRequest(pluginSlug, requestData) {
|
||||
const plugin = this.onlinePlugins.get(pluginSlug);
|
||||
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin ${pluginSlug} is not online`);
|
||||
}
|
||||
|
||||
if (!plugin.ready) {
|
||||
throw new Error(`Plugin ${pluginSlug} is not ready`);
|
||||
}
|
||||
|
||||
const requestId = `${pluginSlug}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Store the promise callbacks
|
||||
plugin.pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set a timeout
|
||||
const timeout = setTimeout(() => {
|
||||
plugin.pendingRequests.delete(requestId);
|
||||
reject(new Error(`Request to plugin ${pluginSlug} timed out`));
|
||||
}, 30000);
|
||||
|
||||
// Send the request to the plugin
|
||||
plugin.process.send({
|
||||
type: 'request',
|
||||
requestId,
|
||||
...requestData,
|
||||
});
|
||||
|
||||
// Clear timeout when resolved
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
|
||||
plugin.pendingRequests.set(requestId, {
|
||||
resolve: data => {
|
||||
clearTimeout(timeout);
|
||||
originalResolve(data);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timeout);
|
||||
originalReject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin is online
|
||||
*/
|
||||
isPluginOnline(pluginSlug) {
|
||||
const plugin = this.onlinePlugins.get(pluginSlug);
|
||||
return plugin && plugin.ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin information
|
||||
*/
|
||||
getPlugin(pluginSlug) {
|
||||
return this.onlinePlugins.get(pluginSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all online plugins
|
||||
*/
|
||||
getOnlinePlugins() {
|
||||
return Array.from(this.onlinePlugins.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bank sync plugins
|
||||
* Returns plugins that have bankSync configuration enabled
|
||||
*/
|
||||
getBankSyncPlugins() {
|
||||
const bankSyncPlugins = [];
|
||||
|
||||
for (const [slug, plugin] of this.onlinePlugins) {
|
||||
if (plugin.manifest?.bankSync?.enabled) {
|
||||
bankSyncPlugins.push({
|
||||
slug,
|
||||
name: plugin.manifest.name,
|
||||
displayName: plugin.manifest.bankSync.displayName,
|
||||
description:
|
||||
plugin.manifest.bankSync.description || plugin.manifest.description,
|
||||
version: plugin.manifest.version,
|
||||
endpoints: plugin.manifest.bankSync.endpoints,
|
||||
requiresAuth: plugin.manifest.bankSync.requiresAuth ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bankSyncPlugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug plugin routes and their authentication requirements
|
||||
* Only outputs when DEBUG=actual:config is set
|
||||
*/
|
||||
debugPluginRoutes(pluginSlug) {
|
||||
const plugin = this.onlinePlugins.get(pluginSlug);
|
||||
if (!plugin || !plugin.manifest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = plugin.manifest;
|
||||
|
||||
debug(`Plugin: ${pluginSlug}`);
|
||||
debug(` Version: ${manifest.version}`);
|
||||
debug(` Description: ${manifest.description || 'N/A'}`);
|
||||
debug(` Entry: ${manifest.entry}`);
|
||||
|
||||
// Show bank sync configuration if enabled
|
||||
if (manifest.bankSync && manifest.bankSync.enabled) {
|
||||
debug(` Bank Sync: ${manifest.bankSync.displayName}`);
|
||||
debug(` Status endpoint: ${manifest.bankSync.endpoints.status}`);
|
||||
debug(` Accounts endpoint: ${manifest.bankSync.endpoints.accounts}`);
|
||||
debug(
|
||||
` Transactions endpoint: ${manifest.bankSync.endpoints.transactions}`,
|
||||
);
|
||||
debug(` Requires auth: ${manifest.bankSync.requiresAuth ?? true}`);
|
||||
}
|
||||
|
||||
if (manifest.routes && manifest.routes.length > 0) {
|
||||
debug(` Routes (${manifest.routes.length}):`);
|
||||
|
||||
for (const route of manifest.routes) {
|
||||
const methods = route.methods.join(', ');
|
||||
const auth = route.auth || 'authenticated'; // Default to authenticated
|
||||
const authLabel =
|
||||
auth === 'anonymous'
|
||||
? '🌍 anonymous'
|
||||
: auth === 'admin'
|
||||
? '🔐 admin'
|
||||
: '🔒 authenticated';
|
||||
|
||||
debug(
|
||||
` ${authLabel} | ${methods.padEnd(15)} | /plugins-api/${pluginSlug}${route.path}`,
|
||||
);
|
||||
|
||||
if (route.description) {
|
||||
debug(` └─ ${route.description}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug(` Routes: none defined`);
|
||||
}
|
||||
|
||||
debug(''); // Empty line for readability
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all plugins
|
||||
*/
|
||||
async shutdown() {
|
||||
const shutdownPromises = [];
|
||||
|
||||
for (const [_pluginSlug, plugin] of this.onlinePlugins) {
|
||||
shutdownPromises.push(
|
||||
new Promise(resolve => {
|
||||
plugin.process.once('exit', resolve);
|
||||
plugin.process.kill();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(shutdownPromises);
|
||||
this.onlinePlugins.clear();
|
||||
|
||||
// Clean up extracted zip plugins
|
||||
for (const [pluginSlug, extractPath] of this.extractedPlugins) {
|
||||
try {
|
||||
if (fs.existsSync(extractPath)) {
|
||||
fs.rmSync(extractPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up extracted plugin: ${pluginSlug}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to clean up plugin ${pluginSlug}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.extractedPlugins.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export { PluginManager };
|
||||
107
packages/sync-server/src/plugin-middleware.js
Normal file
107
packages/sync-server/src/plugin-middleware.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
extractUserFromHeaders,
|
||||
checkAuth,
|
||||
getRouteAuthLevel,
|
||||
} from './auth-checker.js';
|
||||
|
||||
/**
|
||||
* Express middleware that intercepts requests to /<plugin-slug>/<plugin-route>
|
||||
* and forwards them to the appropriate plugin via IPC
|
||||
*
|
||||
* Note: This middleware expects to be mounted at /plugins-api/ in the main app
|
||||
*/
|
||||
function createPluginMiddleware(pluginManager) {
|
||||
return async (req, res, _next) => {
|
||||
// Check if the request is for a plugin
|
||||
// Since this is mounted at /plugins-api/, req.path will be /<plugin-slug>/<plugin-route>
|
||||
const pluginMatch = req.path.match(/^\/([^/]+)\/(.*)$/);
|
||||
|
||||
if (!pluginMatch) {
|
||||
// Not a valid plugin request format
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
message:
|
||||
'Invalid plugin route format. Expected: /plugins-api/<plugin-slug>/<route>',
|
||||
});
|
||||
}
|
||||
|
||||
const [, pluginSlug, pluginRoute] = pluginMatch;
|
||||
|
||||
// Check if the plugin is online
|
||||
if (!pluginManager.isPluginOnline(pluginSlug)) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: `Plugin '${pluginSlug}' is not available`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get plugin manifest for auth checking
|
||||
const plugin = pluginManager.getPlugin(pluginSlug);
|
||||
if (!plugin || !plugin.manifest) {
|
||||
return res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: 'Plugin configuration not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine required auth level for this route
|
||||
const authLevel = getRouteAuthLevel(
|
||||
plugin.manifest,
|
||||
req.method,
|
||||
'/' + pluginRoute,
|
||||
);
|
||||
|
||||
// Extract user information from request
|
||||
const user = extractUserFromHeaders(req.headers);
|
||||
|
||||
// Check if user meets auth requirements
|
||||
const authCheck = checkAuth(user, authLevel);
|
||||
|
||||
if (!authCheck.allowed) {
|
||||
return res.status(authCheck.status).json({
|
||||
error: authCheck.error,
|
||||
message: authCheck.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare request data to send to plugin
|
||||
const requestData = {
|
||||
method: req.method,
|
||||
path: '/' + pluginRoute,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
user, // Pass user info for secrets access
|
||||
pluginSlug, // Pass plugin slug for namespaced secrets
|
||||
};
|
||||
|
||||
// Send request to plugin via IPC
|
||||
const response = await pluginManager.sendRequest(pluginSlug, requestData);
|
||||
|
||||
// Set response headers
|
||||
if (response.headers) {
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Send response
|
||||
res.status(response.status || 200);
|
||||
|
||||
if (typeof response.body === 'string') {
|
||||
res.send(response.body);
|
||||
} else {
|
||||
res.json(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error forwarding request to plugin ${pluginSlug}:`, error);
|
||||
res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: 'Failed to process plugin request',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { createPluginMiddleware };
|
||||
11
packages/sync-server/src/plugins/index.js
Normal file
11
packages/sync-server/src/plugins/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Plugin system entry point for sync-server
|
||||
*
|
||||
* This module exports the plugin manager and middleware
|
||||
* for use in the sync-server application
|
||||
*/
|
||||
|
||||
import { PluginManager } from '../plugin-manager.js';
|
||||
import { createPluginMiddleware } from '../plugin-middleware.js';
|
||||
|
||||
export { PluginManager, createPluginMiddleware };
|
||||
@@ -4,18 +4,11 @@ import { getAccountDb } from '../account-db.js';
|
||||
|
||||
/**
|
||||
* An enum of valid secret names.
|
||||
* Plugin-based providers manage their own secrets via the plugin system.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const SecretName = {
|
||||
gocardless_secretId: 'gocardless_secretId',
|
||||
gocardless_secretKey: 'gocardless_secretKey',
|
||||
simplefin_token: 'simplefin_token',
|
||||
simplefin_accessKey: 'simplefin_accessKey',
|
||||
pluggyai_clientId: 'pluggyai_clientId',
|
||||
pluggyai_clientSecret: 'pluggyai_clientSecret',
|
||||
pluggyai_itemIds: 'pluggyai_itemIds',
|
||||
};
|
||||
export const SecretName = {};
|
||||
|
||||
class SecretsDb {
|
||||
constructor() {
|
||||
|
||||
Reference in New Issue
Block a user