Compare commits

...

9 Commits

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

This enables the plugin architecture for bank sync while preserving existing functionality, ready for feature flag control.
2025-10-08 11:12:01 -03:00
79 changed files with 80375 additions and 136 deletions

605
PLUGIN_ARCHITECTURE.md Normal file
View File

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

View File

@@ -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',

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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 ')}

View File

@@ -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({

View 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,
};
}

View 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,
};
}

View File

@@ -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 };

View File

@@ -18,9 +18,6 @@ module.exports = {
create(context) {
const whitelist = [
'Actual',
'GoCardless',
'SimpleFIN',
'Pluggy.ai',
'YNAB',
'nYNAB',
'YNAB4',

View File

@@ -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)));

View File

@@ -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);

View File

@@ -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();

View File

@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env
.env.local
*.tgz

View 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

View 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"}

View 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);

View 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"}

View 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);
}

View 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"}

View 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 };
}

View 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"}

View 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 = {}));

View 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"
}
}

View 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';

View 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);
}

View 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 };
}

View 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;
}

View 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"]
}

View File

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

File diff suppressed because it is too large Load Diff

View 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');

View 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;

View 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"
}
]
}

View 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"
}
}

View File

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

View File

@@ -0,0 +1,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();

View File

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

View File

@@ -0,0 +1,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');

View 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;

View File

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

View File

@@ -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",

View 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 };

View File

@@ -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);

View File

@@ -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'));
}

View 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,
};

View 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 };

View 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 };

View 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 };

View File

@@ -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() {

883
yarn.lock

File diff suppressed because it is too large Load Diff