Compare commits
271 Commits
v25.7.1
...
sync-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef14e1a79 | ||
|
|
1e5d5b9b78 | ||
|
|
33f6ae7f91 | ||
|
|
50fba76c47 | ||
|
|
744ae1625d | ||
|
|
9dda58b61d | ||
|
|
734bb86126 | ||
|
|
efb0d80aa4 | ||
|
|
605206d2f7 | ||
|
|
f7b40fca64 | ||
|
|
dc811552be | ||
|
|
295839ebbb | ||
|
|
99ca34458e | ||
|
|
90ac8d8520 | ||
|
|
52aeec2d59 | ||
|
|
0c280d60f6 | ||
|
|
148ca92584 | ||
|
|
90e848ebe8 | ||
|
|
b034d5039f | ||
|
|
5ac29473f2 | ||
|
|
3b0db2bed7 | ||
|
|
7a886810bc | ||
|
|
8bf0997275 | ||
|
|
2f965266ab | ||
|
|
499f24f7fd | ||
|
|
4c5be62f56 | ||
|
|
1446c7d93f | ||
|
|
ad9980307e | ||
|
|
d4ad31fb0c | ||
|
|
05355788e4 | ||
|
|
805e2b1807 | ||
|
|
e54dc0c1ca | ||
|
|
e1c2f0a181 | ||
|
|
cc2e329e8e | ||
|
|
71f849d1e1 | ||
|
|
0ea8bc1fb4 | ||
|
|
f0c7953c0b | ||
|
|
4cf5f9b183 | ||
|
|
80fd997540 | ||
|
|
da93ddf63b | ||
|
|
7846d2e787 | ||
|
|
ca6d80461a | ||
|
|
fa14cbb697 | ||
|
|
1210a74b4a | ||
|
|
534c1e6680 | ||
|
|
14d436712a | ||
|
|
e9f3925124 | ||
|
|
f28229be99 | ||
|
|
1fc922c672 | ||
|
|
c712217a7c | ||
|
|
3559b2df3a | ||
|
|
6365a8f4bb | ||
|
|
14426b64fd | ||
|
|
65790d4b9c | ||
|
|
9af4ba4d07 | ||
|
|
28caf8eaf9 | ||
|
|
81160256bc | ||
|
|
ca5378c0e8 | ||
|
|
08b5b7fdc7 | ||
|
|
67c0b6911b | ||
|
|
4e9e153989 | ||
|
|
b0321ee265 | ||
|
|
753a105b3d | ||
|
|
5a888d44b9 | ||
|
|
7a4799de94 | ||
|
|
4ad369cd8f | ||
|
|
2c9a66cec6 | ||
|
|
6e96b81799 | ||
|
|
f89d4fd13d | ||
|
|
cc0812113a | ||
|
|
59724d445f | ||
|
|
6b99497d5d | ||
|
|
5f5457b226 | ||
|
|
4bdcb27573 | ||
|
|
8ae070ab12 | ||
|
|
0ca5bec094 | ||
|
|
988bc21818 | ||
|
|
f4419b96de | ||
|
|
e30a38ced8 | ||
|
|
98b91cfb8d | ||
|
|
942d3ea4d5 | ||
|
|
3c9b70df79 | ||
|
|
5c18b53888 | ||
|
|
413398531c | ||
|
|
e4c3d4e12a | ||
|
|
91b838c539 | ||
|
|
9eb0e04c6a | ||
|
|
14bf3d611c | ||
|
|
34b6599da3 | ||
|
|
bc1cd9023c | ||
|
|
5ae9176f5e | ||
|
|
2ed908aff4 | ||
|
|
3318dd56e9 | ||
|
|
00ab11cc40 | ||
|
|
25c83eb64d | ||
|
|
7a420b79f2 | ||
|
|
d2cfedf5e4 | ||
|
|
00a4cfcabf | ||
|
|
a18a05f55a | ||
|
|
b399f290a6 | ||
|
|
7c07295448 | ||
|
|
510dd31de6 | ||
|
|
8e5a88bc55 | ||
|
|
bbf91ccbca | ||
|
|
58bc14e1b3 | ||
|
|
de2966a06c | ||
|
|
90b859fd74 | ||
|
|
fafcee071d | ||
|
|
ed40901534 | ||
|
|
338093836b | ||
|
|
4df05aa37c | ||
|
|
5459b8baca | ||
|
|
073d91a7b7 | ||
|
|
58a638cee2 | ||
|
|
23f1bae7db | ||
|
|
57240284a3 | ||
|
|
6c6d8931bb | ||
|
|
cae8fa4e6f | ||
|
|
48ae371ecc | ||
|
|
e8d93fb797 | ||
|
|
6790f99de2 | ||
|
|
68f0b05aed | ||
|
|
c954d3924e | ||
|
|
adf4bd2d0f | ||
|
|
102c6eaff6 | ||
|
|
21105fc25b | ||
|
|
c69142f58e | ||
|
|
fe32bf14c6 | ||
|
|
92e43bc3b5 | ||
|
|
165be3d0df | ||
|
|
3dd22994b7 | ||
|
|
96bfc69332 | ||
|
|
284fc13161 | ||
|
|
30102b1474 | ||
|
|
3a8eb96d76 | ||
|
|
91a8bc3ef1 | ||
|
|
dc2ab4843f | ||
|
|
89e5676cfb | ||
|
|
645342d47d | ||
|
|
116c695964 | ||
|
|
a5d18929c8 | ||
|
|
989d332e1b | ||
|
|
169d08e721 | ||
|
|
a74da11904 | ||
|
|
cccd66713d | ||
|
|
1ce53b2762 | ||
|
|
d75f984186 | ||
|
|
692ade7254 | ||
|
|
da0ac0b144 | ||
|
|
be20f65b6e | ||
|
|
1067e32028 | ||
|
|
dcb1c69e67 | ||
|
|
f084e28086 | ||
|
|
f54e459e03 | ||
|
|
ccdde60bfe | ||
|
|
712d315229 | ||
|
|
31c6362307 | ||
|
|
d1519993d6 | ||
|
|
ebde78434a | ||
|
|
8fcaff8e3a | ||
|
|
13bc99738f | ||
|
|
959824d317 | ||
|
|
2abc144b03 | ||
|
|
71250f5fb7 | ||
|
|
c5f050f6f8 | ||
|
|
0d46e221f9 | ||
|
|
6bf2f581a3 | ||
|
|
3c34603111 | ||
|
|
3e488ae8f7 | ||
|
|
bacf3091b6 | ||
|
|
ac77c0f360 | ||
|
|
e21256e7a2 | ||
|
|
22237d11ca | ||
|
|
63604c1161 | ||
|
|
74b95ca83e | ||
|
|
6a9028464b | ||
|
|
186d417c6e | ||
|
|
c898116412 | ||
|
|
85bd6bfb81 | ||
|
|
72616376e2 | ||
|
|
136ad055f7 | ||
|
|
c621f68e0a | ||
|
|
4f611ca458 | ||
|
|
d98e8375a8 | ||
|
|
4f50c1a889 | ||
|
|
0fa582b3d3 | ||
|
|
80cd2cf347 | ||
|
|
f328332ab2 | ||
|
|
7465bdb54d | ||
|
|
1ad3406e84 | ||
|
|
48166952ce | ||
|
|
994b959050 | ||
|
|
7f4f5005a5 | ||
|
|
1e05b169c8 | ||
|
|
c54a5b3405 | ||
|
|
5c11a0a51a | ||
|
|
8f69669cc6 | ||
|
|
9ebdba27fd | ||
|
|
8df3d23e03 | ||
|
|
5d238c238d | ||
|
|
e69235a35b | ||
|
|
611f7b046a | ||
|
|
ee8f7453ba | ||
|
|
380fae1ccd | ||
|
|
8d84f16604 | ||
|
|
239a087542 | ||
|
|
895d69f875 | ||
|
|
1cb5e97fab | ||
|
|
13bd08d243 | ||
|
|
d946852ee9 | ||
|
|
4cdff76547 | ||
|
|
1a1975b5ab | ||
|
|
6e2154d401 | ||
|
|
45bfd23daa | ||
|
|
c44b32805f | ||
|
|
45530638fe | ||
|
|
a15ff85c20 | ||
|
|
1861060bda | ||
|
|
9281acb819 | ||
|
|
dc86441809 | ||
|
|
78bcac95ed | ||
|
|
45610bae81 | ||
|
|
f5a6700b21 | ||
|
|
8cea059834 | ||
|
|
31b31f2edb | ||
|
|
4b56e0cb54 | ||
|
|
f8dd4897e7 | ||
|
|
526d7a9baa | ||
|
|
af8f26f200 | ||
|
|
548f36e8d6 | ||
|
|
9aed1f400c | ||
|
|
59bcee9369 | ||
|
|
ff7529f6e5 | ||
|
|
77e99af297 | ||
|
|
82a3c97222 | ||
|
|
cafcc823cb | ||
|
|
582e27dbdb | ||
|
|
d747563915 | ||
|
|
84dec137bb | ||
|
|
76de8bf67f | ||
|
|
5db7026435 | ||
|
|
e4a993ad67 | ||
|
|
38ab63638a | ||
|
|
4eea349966 | ||
|
|
f66baeabd3 | ||
|
|
19180138bb | ||
|
|
7705a2df08 | ||
|
|
202af094af | ||
|
|
333e7ff7bc | ||
|
|
eb11e14e94 | ||
|
|
dbcfb63857 | ||
|
|
64f4d200dd | ||
|
|
4be7e03570 | ||
|
|
ca969cc61b | ||
|
|
682d439c34 | ||
|
|
37d91a90f7 | ||
|
|
3a09d91399 | ||
|
|
405c8b986f | ||
|
|
bd9f0aec89 | ||
|
|
a4f0d5bca8 | ||
|
|
0085c3b58a | ||
|
|
cfb0f51e36 | ||
|
|
2d95fe6d03 | ||
|
|
80bb4ab299 | ||
|
|
2f71e007d6 | ||
|
|
7b71374e79 | ||
|
|
c1d70722b8 | ||
|
|
2cd79960a9 | ||
|
|
9a6afda6de | ||
|
|
979fa43c4a | ||
|
|
a20805bfae |
12
.cursor/rules/commands.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
|
||||
|
||||
The following commands can be useful:
|
||||
|
||||
- `yarn typecheck` to run typechecker
|
||||
- `yarn lint` to run the code linter and formatter
|
||||
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
|
||||
- `yarn test` to run all the tests
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Before pushing code changes or opening a pull request, follow these steps:
|
||||
|
||||
1. Check if your branch already has a changelog file in the "upcoming-release-notes" folder.
|
||||
2. If there is no changelog file for your branch:
|
||||
a. Find the number of the most recent (highest-numbered) open issue or pull request on GitHub.
|
||||
b. Increment that number by 1. Use this as the filename for your new changelog file.
|
||||
c. Create a new markdown file in the "upcoming-release-notes" folder with the following format:
|
||||
|
||||
```
|
||||
---
|
||||
category: Features OR Maintenance OR Enhancements OR Bugfix
|
||||
authors: [$GithubUsername]
|
||||
---
|
||||
|
||||
$Description
|
||||
```
|
||||
|
||||
3. Commit the new changelog file.
|
||||
4. Proceed with your push or pull request.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -13,6 +13,7 @@ Code Style and Structure
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
|
||||
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
|
||||
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
|
||||
|
||||
Naming Conventions
|
||||
|
||||
@@ -20,7 +21,7 @@ Naming Conventions
|
||||
|
||||
TypeScript Usage
|
||||
|
||||
- Use TypeScript for all code; prefer interfaces over types.
|
||||
- Use TypeScript for all code; prefer types over interfaces.
|
||||
- Avoid enums; use objects or maps instead.
|
||||
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
|
||||
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
|
||||
@@ -30,3 +31,7 @@ Syntax and Formatting
|
||||
- Use the "function" keyword for pure functions.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use declarative JSX, keeping JSX minimal and readable.
|
||||
|
||||
Change validation
|
||||
|
||||
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -2,6 +2,7 @@ name: Bug Report
|
||||
description: File a bug report also known as an issue or problem.
|
||||
title: '[Bug]: '
|
||||
labels: ['needs triage', 'bug']
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -2,6 +2,7 @@ name: Feature request
|
||||
description: Request a missing feature
|
||||
title: '[Feature] '
|
||||
labels: ['feature']
|
||||
type: Feature
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -57,7 +57,8 @@ async function commentOnPR() {
|
||||
`**Summary:** ${summaryData.summary}`,
|
||||
`**File:** [upcoming-release-notes/${summaryData.prNumber}.md](${fileUrl})`,
|
||||
'',
|
||||
'The release notes file has been committed to the repository. You can edit it if needed before merging.',
|
||||
// 'The release notes file has been committed to the repository. You can edit it if needed before merging.',
|
||||
"If you're happy with this release note, you can add it to your pull request. If not, you'll need to add your own before a maintainer can review your change.",
|
||||
].join('\n');
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
|
||||
53
.github/actions/bump-package-versions
vendored
@@ -1,53 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
version="${1#v}"
|
||||
else
|
||||
version=""
|
||||
fi
|
||||
|
||||
files_to_bump=(
|
||||
packages/api/package.json
|
||||
packages/desktop-client/package.json
|
||||
packages/desktop-electron/package.json
|
||||
packages/sync-server/package.json
|
||||
)
|
||||
|
||||
for file in "${files_to_bump[@]}"; do
|
||||
if [ -z "$version" ]; then
|
||||
# version format: YY.MM.patch
|
||||
version="$(jq -r .version "$file" | perl -e '
|
||||
($y,$m,$p)=split(/\./,<>);
|
||||
($sec,$min,$hour,$day,$mon,$year)=localtime();
|
||||
$year -= 100; # Perl year starts at 1900
|
||||
$mon++; # Adjust 0-indexed month to 1-indexed
|
||||
if ($y == $year && $m == $mon) {
|
||||
if ($day <= 25) {
|
||||
# Patch release for the current month
|
||||
$p++;
|
||||
} else {
|
||||
# Use next month for a new release period
|
||||
$p = 0;
|
||||
$m++;
|
||||
$m > 12 && ($m=1, $y++);
|
||||
}
|
||||
} else {
|
||||
# Use the current date for a new release period
|
||||
$y = $year;
|
||||
$m = $mon;
|
||||
$p = 0;
|
||||
}
|
||||
print "$y.$m.$p\n";
|
||||
')"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: Failed to calculate new version" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Bumping $file to version $version"
|
||||
jq '.version = "'"$version"'"' "$file" > "$file.tmp"
|
||||
mv "$file.tmp" "$file"
|
||||
done
|
||||
94
.github/actions/get-next-package-version.js
vendored
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
const { parseArgs } = require('node:util');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
},
|
||||
type: {
|
||||
type: 'string', // nightly, hotfix, monthly
|
||||
short: 't',
|
||||
},
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
|
||||
// Read and parse package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
// Parse year and month from version (e.g. 25.5.1 -> year=2025, month=5)
|
||||
const versionParts = currentVersion.split('.');
|
||||
const versionYear = parseInt(versionParts[0]);
|
||||
const versionMonth = parseInt(versionParts[1]);
|
||||
const versionHotfix = parseInt(versionParts[2]);
|
||||
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const nextVersionYear = nextVersionMonthDate
|
||||
.getFullYear()
|
||||
.toString()
|
||||
.slice(-2);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
|
||||
// Get current date string
|
||||
const currentDate = new Date()
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (values.type) {
|
||||
case 'nightly': {
|
||||
const newVersion = `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDate}`;
|
||||
process.stdout.write(newVersion); // return the new version to stdout
|
||||
process.exit();
|
||||
}
|
||||
case 'hotfix': {
|
||||
const bugfixVersion = `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
|
||||
process.stdout.write(bugfixVersion); // return the bugfix version to stdout
|
||||
process.exit();
|
||||
}
|
||||
case 'monthly': {
|
||||
const stableVersion = `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
process.stdout.write(stableVersion); // return the stable version to stdout
|
||||
process.exit();
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
'Invalid type specified. Use "nightly", "hotfix", or "monthly".',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
259
.github/scripts/count-points.mjs
vendored
@@ -1,5 +1,8 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
const limit = pLimit(50);
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
@@ -10,9 +13,10 @@ const REPOSITORY_CONFIG = new Map([
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 0,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
{ minChanges: 500, points: 8 },
|
||||
{ minChanges: 100, points: 6 },
|
||||
{ minChanges: 10, points: 2 },
|
||||
{ minChanges: 0, points: 1 },
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
@@ -29,8 +33,8 @@ const REPOSITORY_CONFIG = new Map([
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 2000, points: 6 },
|
||||
{ minChanges: 200, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
|
||||
@@ -45,24 +49,25 @@ const REPOSITORY_CONFIG = new Map([
|
||||
function getLastMonthDates() {
|
||||
// Get data relating to the last month
|
||||
const now = new Date();
|
||||
// Always use UTC for calculations
|
||||
const firstDayOfLastMonth = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - 1,
|
||||
1,
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0),
|
||||
);
|
||||
const since = process.env.START_DATE
|
||||
? new Date(process.env.START_DATE)
|
||||
? new Date(Date.parse(process.env.START_DATE))
|
||||
: firstDayOfLastMonth;
|
||||
|
||||
// Calculate the end of the month for the since date
|
||||
// Calculate the end of the month for the since date in UTC
|
||||
const until = new Date(
|
||||
since.getFullYear(),
|
||||
since.getMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
Date.UTC(
|
||||
since.getUTCFullYear(),
|
||||
since.getUTCMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
),
|
||||
);
|
||||
|
||||
return { since, until };
|
||||
@@ -75,7 +80,9 @@ function getLastMonthDates() {
|
||||
* @returns {number} The total points earned for the repository
|
||||
*/
|
||||
async function countContributorPoints(repo) {
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
const owner = 'actualbudget';
|
||||
const config = REPOSITORY_CONFIG.get(repo);
|
||||
|
||||
@@ -122,128 +129,134 @@ async function countContributorPoints(repo) {
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
octokit.search.issuesAndPullRequests,
|
||||
'GET /search/issues',
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data,
|
||||
response => response.data.filter(pr => pr.number),
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
for (const pr of recentPRs) {
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Get list of modified files
|
||||
const { data: modifiedFiles } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Calculate points based on PR size, excluding specified files
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
await Promise.all(
|
||||
recentPRs.map(pr =>
|
||||
limit(async () => {
|
||||
const [reviews, modifiedFiles] = await Promise.all([
|
||||
octokit.pulls.listReviews({ owner, repo, pull_number: pr.number }),
|
||||
octokit.paginate(
|
||||
octokit.pulls.listFiles,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
},
|
||||
res => res.data,
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
]);
|
||||
|
||||
// Check if this is a release PR
|
||||
const isReleasePR = pr.title.match(/^🔖 \(\d+\.\d+\.\d+\)/);
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
|
||||
// Calculate points for reviewers based on PR size
|
||||
const prPoints = config.PR_REVIEW_POINT_TIERS.find(
|
||||
tier => totalChanges > tier.minChanges,
|
||||
).points;
|
||||
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
|
||||
const prPoints =
|
||||
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
|
||||
?.points ?? 0;
|
||||
|
||||
// Add points to the reviewers
|
||||
const uniqueReviewers = new Set();
|
||||
reviews
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
userStats.reviews.push({ pr: pr.number.toString(), points: prPoints });
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
|
||||
// Award points to the PR creator if it's a release PR
|
||||
if (isReleasePR && stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(
|
||||
octokit.issues.listForRepo,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
},
|
||||
(response, done) =>
|
||||
response.data.filter(issue => new Date(issue.updated_at) <= until),
|
||||
if (isReleasePR) {
|
||||
if (stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
} else {
|
||||
const uniqueReviewers = new Set();
|
||||
reviews.data
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
userStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: prPoints,
|
||||
});
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(octokit.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
});
|
||||
|
||||
// Get label events for each issue
|
||||
for (const issue of issues) {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
await Promise.all(
|
||||
issues.map(issue =>
|
||||
limit(async () => {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
// Process events
|
||||
events
|
||||
.filter(
|
||||
event =>
|
||||
new Date(event.created_at) > since &&
|
||||
new Date(event.created_at) <= until &&
|
||||
stats.has(event.actor?.login),
|
||||
)
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label &&
|
||||
event.label.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
events
|
||||
.filter(event => {
|
||||
const createdAt = new Date(event.created_at);
|
||||
return (
|
||||
createdAt.getTime() > since.getTime() &&
|
||||
createdAt.getTime() <= until.getTime() &&
|
||||
stats.has(event.actor?.login)
|
||||
);
|
||||
})
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label?.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
|
||||
if (event.event === 'closed') {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (
|
||||
event.event === 'closed' &&
|
||||
event.state_reason === 'not_planned'
|
||||
) {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Print all statistics
|
||||
printStats(
|
||||
|
||||
10
.github/workflows/docker-edge.yml
vendored
@@ -22,9 +22,9 @@ permissions:
|
||||
|
||||
env:
|
||||
IMAGES: |
|
||||
actualbudget/actual-server
|
||||
ghcr.io/actualbudget/actual-server
|
||||
ghcr.io/actualbudget/actual
|
||||
${{ !github.event.repository.fork && 'actualbudget/actual-server' || '' }}
|
||||
ghcr.io/${{ github.repository_owner }}/actual-server
|
||||
ghcr.io/${{ github.repository_owner }}/actual
|
||||
|
||||
# Creates the following tags:
|
||||
# - actual-server:edge
|
||||
@@ -34,7 +34,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event.repository.fork == false }}
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
6
.github/workflows/e2e-test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
|
||||
2
.github/workflows/electron-master.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
2
.github/workflows/electron-pr.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
25
.github/workflows/generate-release-pr.yml
vendored
@@ -24,8 +24,29 @@ jobs:
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/bump-package-versions ${{ github.event.inputs.version }}
|
||||
echo "version=$(jq -r .version packages/desktop-client/package.json)" > $GITHUB_OUTPUT
|
||||
declare -A packages=(
|
||||
[web]="desktop-client"
|
||||
[electron]="desktop-electron"
|
||||
[sync]="sync-server"
|
||||
[api]="api"
|
||||
)
|
||||
|
||||
for key in "${!packages[@]}"; do
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version="${{ github.event.inputs.version }}"
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
fi
|
||||
|
||||
eval "NEW_${key^^}_VERSION=\"$version\""
|
||||
done
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_WEB_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
|
||||
2
.github/workflows/size-compare.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
base-stats-json-path: ./base/web-stats.json
|
||||
title: desktop-client
|
||||
|
||||
- uses: github/webpack-bundlesize-compare-action@v2.1.0
|
||||
- uses: twk3/rollup-size-compare-action@v1.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/loot-core-stats.json
|
||||
|
||||
2
.github/workflows/update-vrt.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
|
||||
5
.gitignore
vendored
@@ -26,6 +26,8 @@ packages/desktop-electron/build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
bundle.mobile.js
|
||||
@@ -60,3 +62,6 @@ fly.toml
|
||||
|
||||
# TypeScript cache
|
||||
build/
|
||||
|
||||
# .d.ts files aren't type-checked with skipLibCheck set to true
|
||||
*.d.ts
|
||||
|
||||
@@ -7,6 +7,8 @@ packages/api/migrations
|
||||
packages/crdt/dist
|
||||
packages/component-library/src/icons/**/*
|
||||
packages/desktop-client/bundle.browser.js
|
||||
packages/desktop-client/stats.json
|
||||
packages/desktop-client/.swc/
|
||||
packages/desktop-client/build/
|
||||
packages/desktop-client/locale/
|
||||
packages/desktop-client/build-electron/
|
||||
@@ -23,5 +25,6 @@ packages/desktop-electron/dist/
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
packages/sync-server/coverage/
|
||||
.yarn/*
|
||||
upcoming-release-notes/*
|
||||
|
||||
10
CODEOWNERS
Normal file
@@ -0,0 +1,10 @@
|
||||
# CODEOWNERS file for Actual Budget
|
||||
# Please add your name to code-paths that you feel especially
|
||||
# passionate about. You will be notified for any PRs there.
|
||||
|
||||
/packages/api/ @MatissJanis
|
||||
/packages/component-library/ @MatissJanis
|
||||
/packages/desktop-client/src/components/mobile @joel-jeremy
|
||||
/packages/desktop-electron/ @MikesGlitch
|
||||
/packages/loot-core/src/server/budget @youngcw
|
||||
/packages/sync-server/ @matt-fidd
|
||||
605
PLUGIN_ARCHITECTURE.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# Actual Budget Plugin Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Actual Budget's plugin system enables extending the sync-server with custom functionality through isolated, sandboxed processes. Plugins run as separate Node.js child processes that communicate with the sync-server via Inter-Process Communication (IPC).
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
A plugin is a standalone Node.js application that:
|
||||
|
||||
- **Runs as a child process** forked from the sync-server
|
||||
- **Uses Express.js** to define HTTP-like routes
|
||||
- **Communicates via IPC** instead of network sockets
|
||||
- **Has isolated dependencies** and runtime environment
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Plugin Manager** (`sync-server`) - Discovers, loads, and manages plugin lifecycle
|
||||
2. **Plugin Middleware** (`sync-server`) - Routes HTTP requests to appropriate plugins via IPC
|
||||
3. **Plugin Core Library** (`@actual-app/plugins-core-sync-server`) - Utilities for plugin authors
|
||||
4. **Plugin Process** - Your custom plugin code running as a child process
|
||||
|
||||
---
|
||||
|
||||
## Plugin Development
|
||||
|
||||
### 1. Project Setup
|
||||
|
||||
```bash
|
||||
# Create plugin directory
|
||||
mkdir my-plugin
|
||||
cd my-plugin
|
||||
|
||||
# Initialize npm project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install express @actual-app/plugins-core-sync-server
|
||||
npm install -D typescript @types/express @types/node
|
||||
```
|
||||
|
||||
### 2. Create Manifest
|
||||
|
||||
Every plugin needs a `manifest.ts` file that describes the plugin:
|
||||
|
||||
```typescript
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'My awesome plugin',
|
||||
entry: 'dist/index.js',
|
||||
author: 'Your Name',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/hello',
|
||||
methods: ['GET', 'POST'],
|
||||
auth: 'authenticated', // or 'anonymous'
|
||||
description: 'Hello endpoint',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
// Optional: for bank sync plugins
|
||||
enabled: true,
|
||||
displayName: 'My Bank Provider',
|
||||
description: 'Connect accounts via my provider',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
### 3. Create Plugin Code
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Essential: Parse JSON request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Essential: Enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Define your routes
|
||||
app.get('/hello', (req, res) => {
|
||||
res.json({ message: 'Hello from plugin!' });
|
||||
});
|
||||
|
||||
app.post('/save-config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
// Save secrets (encrypted & user-scoped)
|
||||
await saveSecret(req, 'apiKey', apiKey);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/config', async (req, res) => {
|
||||
// Retrieve secrets
|
||||
const result = await getSecret(req, 'apiKey');
|
||||
|
||||
res.json({ configured: !!result.value });
|
||||
});
|
||||
|
||||
// No need to call app.listen() - IPC handles communication
|
||||
console.log('My plugin loaded successfully');
|
||||
```
|
||||
|
||||
### 4. Build Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc && node build-manifest.js",
|
||||
"dev": "tsc --watch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The build process should:
|
||||
|
||||
1. Compile TypeScript to JavaScript
|
||||
2. Convert `manifest.ts` to `manifest.json`
|
||||
|
||||
---
|
||||
|
||||
## Plugin Loading Process
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Sync-Server Starts] --> B[Initialize PluginManager]
|
||||
B --> C[Scan plugins-api Directory]
|
||||
C --> D{Find Plugins}
|
||||
D -->|For each plugin| E[Read manifest.json]
|
||||
E --> F{Valid Manifest?}
|
||||
F -->|No| G[Skip Plugin]
|
||||
F -->|Yes| H[Fork Child Process]
|
||||
H --> I[Pass Environment Variables]
|
||||
I --> J[Plugin Process Starts]
|
||||
J --> K[attachPluginMiddleware Called]
|
||||
K --> L[Plugin Sends 'ready' Message]
|
||||
L --> M{Ready within timeout?}
|
||||
M -->|No| N[Reject Plugin]
|
||||
M -->|Yes| O[Mark Plugin as Online]
|
||||
O --> P[Register Routes]
|
||||
P --> Q[Plugin Available]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style Q fill:#d4edda
|
||||
style G fill:#f8d7da
|
||||
style N fill:#f8d7da
|
||||
```
|
||||
|
||||
### Loading Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginManager
|
||||
participant FS as File System
|
||||
participant PP as Plugin Process
|
||||
|
||||
SS->>PM: Initialize(pluginsDir)
|
||||
SS->>PM: loadPlugins()
|
||||
|
||||
PM->>FS: Read plugins-api directory
|
||||
FS-->>PM: List of plugin folders
|
||||
|
||||
loop For each plugin
|
||||
PM->>FS: Read manifest.json
|
||||
FS-->>PM: Manifest data
|
||||
|
||||
PM->>PM: Validate manifest
|
||||
|
||||
PM->>PP: fork(entryPoint)
|
||||
Note over PP: Plugin process starts
|
||||
|
||||
PP->>PP: Create Express app
|
||||
PP->>PP: Define routes
|
||||
PP->>PP: attachPluginMiddleware()
|
||||
|
||||
PP-->>PM: IPC: {type: 'ready'}
|
||||
|
||||
PM->>PM: Mark plugin as online
|
||||
PM->>PM: Register routes
|
||||
end
|
||||
|
||||
PM-->>SS: All plugins loaded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Communication Architecture
|
||||
|
||||
### HTTP Request Flow
|
||||
|
||||
When a client makes a request to a plugin endpoint:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginMiddleware
|
||||
participant MGR as PluginManager
|
||||
participant PP as Plugin Process
|
||||
|
||||
C->>SS: POST /plugins-api/my-plugin/hello
|
||||
SS->>PM: Route to plugin middleware
|
||||
|
||||
PM->>PM: Extract plugin slug & route
|
||||
PM->>PM: Check authentication
|
||||
PM->>PM: Verify route permissions
|
||||
|
||||
PM->>MGR: sendRequest(pluginSlug, requestData)
|
||||
|
||||
MGR->>PP: IPC: {type: 'request', method, path, body}
|
||||
|
||||
Note over PP: Plugin receives IPC message
|
||||
PP->>PP: Simulate HTTP request
|
||||
PP->>PP: Route to Express handler
|
||||
PP->>PP: Execute business logic
|
||||
|
||||
PP-->>MGR: IPC: {type: 'response', status, body}
|
||||
|
||||
MGR-->>PM: Response data
|
||||
PM-->>SS: Forward response
|
||||
SS-->>C: HTTP Response
|
||||
```
|
||||
|
||||
### IPC Message Types
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Sync-Server → Plugin"
|
||||
A[request<br/>HTTP request data]
|
||||
B[secret-response<br/>Secret value response]
|
||||
end
|
||||
|
||||
subgraph "Plugin → Sync-Server"
|
||||
C[ready<br/>Plugin initialized]
|
||||
D[response<br/>HTTP response data]
|
||||
E[secret-get<br/>Request secret]
|
||||
F[secret-set<br/>Save secret]
|
||||
G[error<br/>Error occurred]
|
||||
end
|
||||
|
||||
style A fill:#fff3cd
|
||||
style B fill:#fff3cd
|
||||
style C fill:#d4edda
|
||||
style D fill:#d4edda
|
||||
style E fill:#d1ecf1
|
||||
style F fill:#d1ecf1
|
||||
style G fill:#f8d7da
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
Plugins can store encrypted, user-scoped secrets (API keys, tokens, etc.):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PH as Plugin Handler
|
||||
participant PC as Plugin Core
|
||||
participant PP as Plugin Process (IPC)
|
||||
participant PM as PluginManager
|
||||
participant SS as Secrets Store
|
||||
|
||||
Note over PH: User saves API key
|
||||
|
||||
PH->>PC: saveSecret(req, 'apiKey', 'abc123')
|
||||
PC->>PC: Namespace: 'my-plugin_apiKey'
|
||||
PC->>PP: process.send({type: 'secret-set'})
|
||||
|
||||
PP-->>PM: IPC: secret-set message
|
||||
PM->>SS: Store secret (encrypted)
|
||||
SS-->>PM: Success
|
||||
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {success: true}
|
||||
|
||||
Note over PH: Later: retrieve secret
|
||||
|
||||
PH->>PC: getSecret(req, 'apiKey')
|
||||
PC->>PP: process.send({type: 'secret-get'})
|
||||
PP-->>PM: IPC: secret-get message
|
||||
PM->>SS: Retrieve secret
|
||||
SS-->>PM: Decrypted value
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {value: 'abc123'}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **User-scoped**: Each user has their own secrets
|
||||
- **Encrypted**: Stored securely in the database
|
||||
- **Namespaced**: Automatically prefixed with plugin slug
|
||||
- **Async**: Uses IPC promises for retrieval
|
||||
|
||||
---
|
||||
|
||||
## Plugin Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["Client (Browser/App)"]
|
||||
UI[User Interface]
|
||||
end
|
||||
|
||||
subgraph SyncServer["Sync-Server Process"]
|
||||
HTTP[HTTP Server]
|
||||
AUTH[Authentication]
|
||||
API[API Routes]
|
||||
PMW[Plugin Middleware]
|
||||
MGR[Plugin Manager]
|
||||
SEC[Secrets Store]
|
||||
end
|
||||
|
||||
subgraph Plugin1["Plugin Process 1"]
|
||||
P1APP[Express App]
|
||||
P1MW[Plugin Middleware]
|
||||
P1ROUTES[Route Handlers]
|
||||
P1LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
subgraph Plugin2["Plugin Process 2"]
|
||||
P2APP[Express App]
|
||||
P2MW[Plugin Middleware]
|
||||
P2ROUTES[Route Handlers]
|
||||
P2LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
UI -->|HTTP Request| HTTP
|
||||
HTTP --> AUTH
|
||||
AUTH --> API
|
||||
API --> PMW
|
||||
PMW -->|Route| MGR
|
||||
|
||||
MGR <-->|IPC<br/>Messages| P1MW
|
||||
MGR <-->|IPC<br/>Messages| P2MW
|
||||
|
||||
P1MW --> P1APP
|
||||
P1APP --> P1ROUTES
|
||||
P1ROUTES --> P1LOGIC
|
||||
|
||||
P2MW --> P2APP
|
||||
P2APP --> P2ROUTES
|
||||
P2ROUTES --> P2LOGIC
|
||||
|
||||
P1LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
P2LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
MGR <-.-> SEC
|
||||
|
||||
style Client fill:#e1f5ff
|
||||
style SyncServer fill:#fff3cd
|
||||
style Plugin1 fill:#d4edda
|
||||
style Plugin2 fill:#d4edda
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bank Sync Plugins
|
||||
|
||||
Bank sync plugins follow a specific contract to integrate with Actual's account linking:
|
||||
|
||||
### Required Endpoints
|
||||
|
||||
1. **`/status`** - Check if plugin is configured
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": { "configured": true }
|
||||
}
|
||||
```
|
||||
|
||||
2. **`/accounts`** - Fetch available accounts
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "ext-123",
|
||||
"name": "Checking",
|
||||
"institution": "My Bank",
|
||||
"balance": 1000,
|
||||
"mask": "1234",
|
||||
"official_name": "Primary Checking",
|
||||
"orgDomain": "mybank.com",
|
||||
"orgId": "bank-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **`/transactions`** - Fetch transactions
|
||||
|
||||
```json
|
||||
Request: {
|
||||
"accountId": "ext-123",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"transactions": {
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
|
||||
```typescript
|
||||
app.post('/endpoint', async (req, res) => {
|
||||
try {
|
||||
const result = await doSomething();
|
||||
res.json({ status: 'ok', data: result });
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Input Validation
|
||||
|
||||
```typescript
|
||||
app.post('/config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return res.json({
|
||||
status: 'error',
|
||||
error: 'apiKey is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Process...
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Logging
|
||||
|
||||
```typescript
|
||||
// Plugin stdout/stderr is visible in sync-server logs
|
||||
console.log('[MY-PLUGIN] Processing request...');
|
||||
console.error('[MY-PLUGIN] Error occurred:', error);
|
||||
```
|
||||
|
||||
### 4. Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[MY-PLUGIN] Shutting down...');
|
||||
// Cleanup resources
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
sync-server/
|
||||
└── user-files/
|
||||
└── plugins-api/
|
||||
└── my-plugin/
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Build the plugin** (as ZIP or folder)
|
||||
2. **Place in plugins-api directory**
|
||||
3. **Restart sync-server** (auto-loads on startup)
|
||||
|
||||
### ZIP Format (Recommended)
|
||||
|
||||
```
|
||||
my-plugin.zip
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
The plugin manager automatically extracts ZIPs to a temporary directory.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
- Check `manifest.json` exists and is valid JSON
|
||||
- Verify `entry` field points to correct file
|
||||
- Check sync-server logs for error messages
|
||||
|
||||
### IPC Communication Failures
|
||||
|
||||
- Ensure `attachPluginMiddleware(app)` is called
|
||||
- Verify plugin sends `ready` message within 10s timeout
|
||||
- Check that `process.send` is available (forked process)
|
||||
|
||||
### Route Not Found
|
||||
|
||||
- Verify route is defined in `manifest.json`
|
||||
- Check authentication requirements match
|
||||
- Ensure route path matches exactly (case-sensitive)
|
||||
|
||||
### Secrets Not Persisting
|
||||
|
||||
- Confirm user is authenticated
|
||||
- Check `pluginSlug` is passed in request context
|
||||
- Verify secrets store is properly initialized
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Bank Sync Plugin
|
||||
|
||||
See the [Pluggy.ai plugin](packages/bank-sync-plugin-pluggy.ai/) for a full working example that demonstrates:
|
||||
|
||||
- Authentication and configuration
|
||||
- Account fetching with proper typing
|
||||
- Transaction synchronization
|
||||
- Secret management
|
||||
- Error handling
|
||||
- TypeScript usage
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### `attachPluginMiddleware(app: Express)`
|
||||
|
||||
Enables IPC communication for the plugin. Must be called before defining routes.
|
||||
|
||||
### `saveSecret(req: Request, key: string, value: string)`
|
||||
|
||||
Saves an encrypted, user-scoped secret.
|
||||
|
||||
### `getSecret(req: Request, key: string)`
|
||||
|
||||
Retrieves a secret by key.
|
||||
|
||||
### `saveSecrets(req: Request, secrets: Record<string, string>)`
|
||||
|
||||
Saves multiple secrets at once.
|
||||
|
||||
### `getSecrets(req: Request, keys: string[])`
|
||||
|
||||
Retrieves multiple secrets at once.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Process Isolation** - Each plugin runs in its own process
|
||||
2. **Route Authentication** - Manifest declares auth requirements
|
||||
3. **Secret Encryption** - All secrets encrypted at rest
|
||||
4. **User Scoping** - Secrets isolated per user
|
||||
5. **Namespace Isolation** - Secrets auto-prefixed with plugin slug
|
||||
6. **No Direct DB Access** - Plugins can't access database directly
|
||||
7. **Controlled IPC** - Only specific message types allowed
|
||||
@@ -14,6 +14,9 @@ git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import prompts from 'prompts';
|
||||
|
||||
async function run() {
|
||||
const username = await execAsync(
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
// eslint-disable-next-line actual/typography
|
||||
"gh api user --jq '.login'",
|
||||
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
|
||||
);
|
||||
|
||||
@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
|
||||
echo "E2E_START_URL: $E2E_START_URL"
|
||||
echo "VRT_ARGS: $VRT_ARGS"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import globals from 'globals';
|
||||
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import pluginRulesDir from 'eslint-plugin-rulesdir';
|
||||
import pluginTypescript from 'typescript-eslint';
|
||||
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
|
||||
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
|
||||
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
pluginRulesDir.RULES_DIR = path.join(
|
||||
__dirname,
|
||||
'packages',
|
||||
'eslint-plugin-actual',
|
||||
'lib',
|
||||
'rules',
|
||||
);
|
||||
|
||||
const confusingBrowserGlobals = [
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
'addEventListener',
|
||||
@@ -88,27 +74,27 @@ const confusingBrowserGlobals = [
|
||||
export default pluginTypescript.config(
|
||||
{
|
||||
ignores: [
|
||||
// Global ignore patterns
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.zip',
|
||||
// Specific ignore patterns
|
||||
'packages/api/app/bundle.api.js',
|
||||
'packages/api/app/stats.json',
|
||||
'packages/api/dist',
|
||||
'packages/api/@types',
|
||||
'packages/api/migrations',
|
||||
'packages/crdt/dist',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'packages/desktop-client/bundle.browser.js',
|
||||
'packages/desktop-client/build/',
|
||||
'packages/desktop-client/service-worker/*',
|
||||
'packages/desktop-client/build-electron/',
|
||||
'packages/desktop-client/build-stats/',
|
||||
'packages/desktop-client/public/kcab/',
|
||||
'packages/desktop-client/public/data/',
|
||||
'packages/desktop-client/**/node_modules/*',
|
||||
'packages/desktop-client/node_modules/',
|
||||
'packages/desktop-client/test-results/',
|
||||
'packages/desktop-client/playwright-report/',
|
||||
'packages/desktop-electron/client-build/',
|
||||
'packages/desktop-electron/build/',
|
||||
'packages/desktop-electron/dist/',
|
||||
'packages/loot-core/**/node_modules/*',
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
@@ -167,14 +153,19 @@ export default pluginTypescript.config(
|
||||
pluginImport.flatConfigs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': pluginReactHooks,
|
||||
'jsx-a11y': pluginJSXA11y,
|
||||
rulesdir: pluginRulesDir,
|
||||
'typescript-paths': pluginTypescriptPaths,
|
||||
actual: pluginActual,
|
||||
},
|
||||
rules: {
|
||||
'actual/no-untranslated-strings': 'error',
|
||||
'actual/prefer-trans-over-t': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': pluginJSXA11y,
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
rules: {
|
||||
// http://eslint.org/docs/rules/
|
||||
'array-callback-return': 'warn',
|
||||
@@ -458,8 +449,9 @@ export default pluginTypescript.config(
|
||||
},
|
||||
],
|
||||
|
||||
'rulesdir/typography': 'warn',
|
||||
'rulesdir/prefer-if-statement': 'warn',
|
||||
'actual/typography': 'warn',
|
||||
'actual/prefer-if-statement': 'warn',
|
||||
'actual/prefer-logger-over-console': 'error',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
'no-unused-vars': 'off',
|
||||
@@ -467,6 +459,7 @@ export default pluginTypescript.config(
|
||||
'warn',
|
||||
{
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
argsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
caughtErrors: 'none',
|
||||
},
|
||||
@@ -639,6 +632,9 @@ export default pluginTypescript.config(
|
||||
},
|
||||
{
|
||||
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'typescript-paths': pluginTypescriptPaths,
|
||||
},
|
||||
rules: {
|
||||
'typescript-paths/absolute-parent-import': [
|
||||
'error',
|
||||
@@ -767,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',
|
||||
@@ -778,7 +786,9 @@ export default pluginTypescript.config(
|
||||
],
|
||||
|
||||
rules: {
|
||||
'rulesdir/typography': 'off',
|
||||
'actual/typography': 'off',
|
||||
'actual/no-untranslated-strings': 'off',
|
||||
'actual/prefer-logger-over-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -797,7 +807,7 @@ export default pluginTypescript.config(
|
||||
// TODO: fix the issues in these files
|
||||
rules: {
|
||||
'import/extensions': 'off',
|
||||
'rulesdir/typography': 'off',
|
||||
'actual/typography': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -805,8 +815,6 @@ export default pluginTypescript.config(
|
||||
rules: {
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
// can be re-enabled after https://github.com/actualbudget/actual/pull/4253
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
38
package.json
@@ -23,17 +23,20 @@
|
||||
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
|
||||
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
@@ -41,9 +44,10 @@
|
||||
"test:debug": "yarn workspaces foreach --all --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
|
||||
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "prettier --check . && eslint . --max-warnings 0",
|
||||
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
|
||||
@@ -54,32 +58,32 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/node": "^22.18.8",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.3.5",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"globals": "^15.15.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.2",
|
||||
"lint-staged": "^16.2.3",
|
||||
"minimatch": "^10.0.3",
|
||||
"node-jq": "^6.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.5.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -98,7 +102,7 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"browserslist": [
|
||||
"electron 24.0",
|
||||
"electron >= 35.0",
|
||||
"defaults"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ export async function init(config: InitConfig = {}) {
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('sync');
|
||||
try {
|
||||
await actualApp.send('sync');
|
||||
} catch (e) {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
|
||||
@@ -740,3 +740,122 @@ describe('API CRUD operations', () => {
|
||||
expect(transactions[0].notes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule
|
||||
test('Schedules: successfully complete schedules operations', async () => {
|
||||
await api.loadBudget(budgetName);
|
||||
//test a schedule with a recuring configuration
|
||||
const ScheduleId1 = await api.createSchedule({
|
||||
name: 'test-schedule 1',
|
||||
posts_transaction: true,
|
||||
// amount: -5000,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
});
|
||||
//test the creation of non recurring schedule
|
||||
const ScheduleId2 = await api.createSchedule({
|
||||
name: 'test-schedule 2',
|
||||
posts_transaction: false,
|
||||
amount: 4000,
|
||||
amountOp: 'is',
|
||||
date: '2025-06-13',
|
||||
});
|
||||
let schedules = await api.getSchedules();
|
||||
|
||||
// Schedules successfully created
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'test-schedule 1',
|
||||
posts_transaction: true,
|
||||
// amount: -5000,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'test-schedule 2',
|
||||
posts_transaction: false,
|
||||
amount: 4000,
|
||||
amountOp: 'is',
|
||||
date: '2025-06-13',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
//check getIDByName works on schedules
|
||||
expect(await api.getIDByName('schedules', 'test-schedule 1')).toEqual(
|
||||
ScheduleId1,
|
||||
);
|
||||
expect(await api.getIDByName('schedules', 'test-schedule 2')).toEqual(
|
||||
ScheduleId2,
|
||||
);
|
||||
|
||||
//check getIDByName works on accounts
|
||||
const schedAccountId1 = await api.createAccount(
|
||||
{ name: 'sched-test-account1', offbudget: true },
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(await api.getIDByName('accounts', 'sched-test-account1')).toEqual(
|
||||
schedAccountId1,
|
||||
);
|
||||
|
||||
//check getIDByName works on payees
|
||||
const schedPayeeId1 = await api.createPayee({ name: 'sched-test-payee1' });
|
||||
|
||||
expect(await api.getIDByName('payees', 'sched-test-payee1')).toEqual(
|
||||
schedPayeeId1,
|
||||
);
|
||||
await api.updateSchedule(ScheduleId1, {
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
});
|
||||
await api.deleteSchedule(ScheduleId2);
|
||||
|
||||
// schedules successfully updated, and one of them deleted
|
||||
await api.updateSchedule(ScheduleId1, {
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
payee: schedPayeeId1,
|
||||
});
|
||||
await api.deleteSchedule(ScheduleId2);
|
||||
|
||||
schedules = await api.getSchedules();
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: ScheduleId1,
|
||||
posts_transaction: true,
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
payee: schedPayeeId1,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
}),
|
||||
expect.not.objectContaining({ id: ScheduleId2 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ export function addTransactions(
|
||||
|
||||
export interface ImportTransactionsOpts {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export function importTransactions(
|
||||
@@ -103,11 +104,13 @@ export function importTransactions(
|
||||
transactions: ImportTransactionEntity[],
|
||||
opts: ImportTransactionsOpts = {
|
||||
defaultCleared: true,
|
||||
dryRun: false,
|
||||
},
|
||||
) {
|
||||
return send('api/transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: opts.dryRun,
|
||||
opts,
|
||||
});
|
||||
}
|
||||
@@ -239,3 +242,31 @@ export function holdBudgetForNextMonth(month, amount) {
|
||||
export function resetBudgetHold(month) {
|
||||
return send('api/budget-reset-hold', { month });
|
||||
}
|
||||
|
||||
export function createSchedule(schedule) {
|
||||
return send('api/schedule-create', schedule);
|
||||
}
|
||||
|
||||
export function updateSchedule(id, fields, resetNextDate?: boolean) {
|
||||
return send('api/schedule-update', {
|
||||
id,
|
||||
fields,
|
||||
resetNextDate,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedule(scheduleId) {
|
||||
return send('api/schedule-delete', scheduleId);
|
||||
}
|
||||
|
||||
export function getSchedules() {
|
||||
return send('api/schedules-get');
|
||||
}
|
||||
|
||||
export function getIDByName(type, name) {
|
||||
return send('api/get-id-by-name', { type, name });
|
||||
}
|
||||
|
||||
export function getServerVersion() {
|
||||
return send('api/get-server-version');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "25.7.1",
|
||||
"version": "25.10.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
@@ -24,14 +24,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function amountToInteger(n) {
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
export function integerToAmount(n) {
|
||||
return parseFloat((n / 100).toFixed(2));
|
||||
}
|
||||
1
packages/api/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated build artifacts
|
||||
manifest.json
|
||||
*.zip
|
||||
|
||||
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
Normal file
@@ -0,0 +1,459 @@
|
||||
import { attachPluginMiddleware, saveSecret, getSecret, BankSyncErrorCode, } from '@actual-app/plugins-core-sync-server';
|
||||
import express from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
// Create Express app
|
||||
const app = express();
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
// Pluggy client singleton
|
||||
let pluggyClient = null;
|
||||
async function getPluggyClient(req) {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
return pluggyClient;
|
||||
}
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req, res) => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray;
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id) => id.trim());
|
||||
}
|
||||
else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
}
|
||||
else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id) => id.trim());
|
||||
}
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts = [];
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
account.itemData = item;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`, error);
|
||||
}
|
||||
}
|
||||
accounts = accounts.concat(partial.results);
|
||||
}
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account) => {
|
||||
const institution = account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
const connectorId = account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain: account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post('/transactions', async (req, res) => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
let startingBalance = parseInt(Math.round(account.balance * 100).toString());
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt));
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
const all = [];
|
||||
const booked = [];
|
||||
const pending = [];
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans;
|
||||
const newTrans = {};
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
const transactionDate = new Date(transRecord.date);
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
transRecord.amountInAccountCurrency * -1;
|
||||
}
|
||||
transRecord.amount = transRecord.amount * -1;
|
||||
}
|
||||
let amountInCurrency = transRecord.amountInAccountCurrency ??
|
||||
transRecord.amount;
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
delete transRecord.amount;
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
}
|
||||
else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
const sortFunction = (a, b) => {
|
||||
const aRec = a;
|
||||
const bRec = b;
|
||||
return bRec.sortOrder - aRec.sortOrder;
|
||||
};
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Helper functions
|
||||
async function getTransactions(client, accountId, startDate) {
|
||||
let transactions = [];
|
||||
let result = await getTransactionsByAccountId(client, accountId, startDate, 500, 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(client, accountId, startDate, 500, currentPage + 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
async function getTransactionsByAccountId(client, accountId, startDate, pageSize, page) {
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map((t) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}));
|
||||
transactions.results =
|
||||
mappedResults;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
function getDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
function flattenObject(obj, prefix = '') {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenObject(value, newKey));
|
||||
}
|
||||
else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getPayeeName(trans) {
|
||||
const merchant = trans.merchant;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
const paymentData = trans.paymentData;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver;
|
||||
const docNum = receiverData.documentNumber;
|
||||
return receiverData.name || docNum?.value || '';
|
||||
}
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer;
|
||||
const docNum = payerData.documentNumber;
|
||||
return payerData.name || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
40
packages/bank-sync-plugin-pluggy.ai/dist/manifest.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
export const manifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
export default manifest;
|
||||
45
packages/bank-sync-plugin-pluggy.ai/manifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "pluggy-bank-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank synchronization plugin for Actual Budget",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Check Pluggy.ai configuration status"
|
||||
},
|
||||
{
|
||||
"path": "/accounts",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch accounts from Pluggy.ai"
|
||||
},
|
||||
{
|
||||
"path": "/transactions",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch transactions from Pluggy.ai"
|
||||
}
|
||||
],
|
||||
"bankSync": {
|
||||
"enabled": true,
|
||||
"displayName": "Pluggy.ai",
|
||||
"description": "Connect your bank accounts via Pluggy.ai",
|
||||
"requiresAuth": true,
|
||||
"endpoints": {
|
||||
"status": "/status",
|
||||
"accounts": "/accounts",
|
||||
"transactions": "/transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/bank-sync-plugin-pluggy.ai/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@actual-app/bank-sync-plugin-pluggy.ai",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank sync plugin for Actual Budget",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
|
||||
"build:compile": "tsc",
|
||||
"build:bundle": "node scripts/build-bundle.cjs",
|
||||
"build:manifest": "node scripts/build-manifest.cjs",
|
||||
"build:zip": "node scripts/build-zip.cjs",
|
||||
"deploy": "npm run build && npm run install:plugin",
|
||||
"install:plugin": "node scripts/install-plugin.cjs",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"bank-sync",
|
||||
"pluggy",
|
||||
"pluggyai"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"archiver": "^7.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/plugins-core-sync-server": "workspace:*",
|
||||
"express": "^4.18.0",
|
||||
"pluggy-sdk": "^0.77.0"
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-pluggy.ai/scripts/build-bundle.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to bundle the plugin with all dependencies
|
||||
* Uses esbuild to create a single self-contained JavaScript file
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const { join } = require('path');
|
||||
|
||||
async function bundle() {
|
||||
try {
|
||||
console.log('Bundling plugin with dependencies...');
|
||||
|
||||
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
|
||||
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: [],
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
console.log('Bundle created successfully');
|
||||
console.log(`Output: dist/bundle.js`);
|
||||
} catch (error) {
|
||||
console.error('Failed to bundle:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bundle();
|
||||
51
packages/bank-sync-plugin-pluggy.ai/scripts/build-manifest.cjs
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to convert TypeScript manifest to JSON
|
||||
* This script imports the manifest.ts file and writes it as JSON to manifest.json
|
||||
*/
|
||||
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Import the manifest from the built TypeScript file
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
async function importManifest() {
|
||||
// First try to import from the compiled JavaScript
|
||||
try {
|
||||
const manifestModule = await import('../dist/manifest.js');
|
||||
return manifestModule.manifest;
|
||||
} catch (error) {
|
||||
console.error('Could not import compiled manifest:', error.message);
|
||||
console.log(
|
||||
'Make sure TypeScript is compiled first. Run: npm run build:compile',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest() {
|
||||
try {
|
||||
console.log('Building manifest.json...');
|
||||
|
||||
// Import the manifest from the compiled TypeScript
|
||||
const manifest = await importManifest();
|
||||
|
||||
// Convert to JSON with pretty formatting
|
||||
const jsonContent = JSON.stringify(manifest, null, 2);
|
||||
|
||||
// Write to manifest.json in the root directory
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
writeFileSync(manifestPath, jsonContent + '\n');
|
||||
|
||||
console.log('manifest.json created successfully');
|
||||
console.log(`Package: ${manifest.name}@${manifest.version}`);
|
||||
console.log(`Description: ${manifest.description}`);
|
||||
console.log(`Entry point: ${manifest.entry}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
89
packages/bank-sync-plugin-pluggy.ai/scripts/build-zip.cjs
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to create a plugin distribution zip file
|
||||
* Creates: {packageName}.{version}.zip containing dist/index.js and manifest.json
|
||||
*/
|
||||
|
||||
const { createWriteStream, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// Import package.json to get name and version
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
function importPackageJson() {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
return packageJson;
|
||||
} catch (error) {
|
||||
console.error('Could not import package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZip() {
|
||||
try {
|
||||
console.log('Creating plugin distribution zip...');
|
||||
|
||||
// Get package info
|
||||
const packageJson = importPackageJson();
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
|
||||
// Create zip filename
|
||||
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
|
||||
const zipPath = join(__dirname, '..', zipFilename);
|
||||
|
||||
console.log(`Creating ${zipFilename}`);
|
||||
|
||||
// Check if required files exist
|
||||
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.error('dist/bundle.js not found. Run: npm run build:bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error('manifest.json not found. Run: npm run build:manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create zip file
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }, // Maximum compression
|
||||
});
|
||||
|
||||
// Handle archive events
|
||||
archive.on('error', err => {
|
||||
console.error('Archive error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
archive.on('end', () => {
|
||||
const stats = archive.pointer();
|
||||
console.log(`${zipFilename} created successfully`);
|
||||
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
|
||||
console.log(
|
||||
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
|
||||
);
|
||||
});
|
||||
|
||||
// Pipe archive to file
|
||||
archive.pipe(output);
|
||||
|
||||
// Add files to archive
|
||||
archive.file(bundlePath, { name: 'index.js' });
|
||||
archive.file(manifestPath, { name: 'manifest.json' });
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Failed to create zip:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createZip();
|
||||
70
packages/bank-sync-plugin-pluggy.ai/scripts/install-plugin.cjs
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
const pluginName = packageName.replace('@', '').replace('/', '-');
|
||||
const zipFileName = `${pluginName}.${version}.zip`;
|
||||
|
||||
// Source: built zip in package root (not in dist/)
|
||||
const sourceZip = path.join(__dirname, '..', zipFileName);
|
||||
|
||||
// Target: sync-server plugins directory
|
||||
// Go up to monorepo root, then to sync-server
|
||||
const targetDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'sync-server',
|
||||
'server-files',
|
||||
'plugins',
|
||||
);
|
||||
const targetZip = path.join(targetDir, zipFileName);
|
||||
|
||||
console.log('📦 Installing plugin to sync-server...');
|
||||
console.log(` Source: ${sourceZip}`);
|
||||
console.log(` Target: ${targetZip}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(sourceZip)) {
|
||||
console.error(`Error: ZIP file not found at ${sourceZip}`);
|
||||
console.error(' Run "npm run build" first to create the ZIP file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create target directory if it doesn't exist
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(`Creating plugins directory: ${targetDir}`);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove old versions of this plugin
|
||||
try {
|
||||
const files = fs.readdirSync(targetDir);
|
||||
const oldVersions = files.filter(
|
||||
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
|
||||
);
|
||||
|
||||
for (const oldFile of oldVersions) {
|
||||
const oldPath = path.join(targetDir, oldFile);
|
||||
console.log(` Removing old version: ${oldFile}`);
|
||||
fs.unlinkSync(oldPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` Warning: Could not clean old versions: ${err.message}`);
|
||||
}
|
||||
|
||||
// Copy the new ZIP
|
||||
try {
|
||||
fs.copyFileSync(sourceZip, targetZip);
|
||||
console.log(` Plugin installed successfully!`);
|
||||
console.log(` Location: ${targetZip}`);
|
||||
console.log('');
|
||||
console.log(' Restart your sync-server to load the plugin.');
|
||||
} catch (err) {
|
||||
console.error(` Error copying file: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
605
packages/bank-sync-plugin-pluggy.ai/src/index.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
BankSyncErrorCode,
|
||||
BankSyncError,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
|
||||
// Type definitions for Pluggy account structure
|
||||
type PluggyConnector = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
institutionUrl?: string;
|
||||
};
|
||||
|
||||
type PluggyItem = {
|
||||
connector?: PluggyConnector;
|
||||
};
|
||||
|
||||
type PluggyAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
number?: string;
|
||||
balance?: number;
|
||||
type?: string;
|
||||
itemId?: string;
|
||||
item?: PluggyItem;
|
||||
itemData?: PluggyItem;
|
||||
updatedAt?: string;
|
||||
currencyCode?: string;
|
||||
owner?: string;
|
||||
};
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Pluggy client singleton
|
||||
let pluggyClient: PluggyClient | null = null;
|
||||
|
||||
async function getPluggyClient(req: Request): Promise<PluggyClient> {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
return pluggyClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray: string[];
|
||||
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id: string) => id.trim());
|
||||
} else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
} else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
} else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error:
|
||||
'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id: string) => id.trim());
|
||||
}
|
||||
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts: PluggyAccount[] = [];
|
||||
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
(account as PluggyAccount).itemData = item;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accounts = accounts.concat(partial.results as PluggyAccount[]);
|
||||
}
|
||||
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account: PluggyAccount) => {
|
||||
const institution =
|
||||
account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
|
||||
const connectorId =
|
||||
account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain:
|
||||
account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post(
|
||||
'/transactions',
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
let startingBalance = parseInt(
|
||||
Math.round((account.balance as number) * 100).toString(),
|
||||
);
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt as string));
|
||||
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
|
||||
const all: unknown[] = [];
|
||||
const booked: unknown[] = [];
|
||||
const pending: unknown[] = [];
|
||||
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans as Record<string, unknown>;
|
||||
const newTrans: Record<string, unknown> = {};
|
||||
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
|
||||
const transactionDate = new Date(transRecord.date as string);
|
||||
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) * -1;
|
||||
}
|
||||
|
||||
transRecord.amount = (transRecord.amount as number) * -1;
|
||||
}
|
||||
|
||||
let amountInCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) ??
|
||||
(transRecord.amount as number);
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
|
||||
delete transRecord.amount;
|
||||
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
} else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
|
||||
const sortFunction = (a: unknown, b: unknown) => {
|
||||
const aRec = a as Record<string, unknown>;
|
||||
const bRec = b as Record<string, unknown>;
|
||||
return (bRec.sortOrder as number) - (aRec.sortOrder as number);
|
||||
};
|
||||
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
async function getTransactions(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
): Promise<unknown[]> {
|
||||
let transactions: unknown[] = [];
|
||||
let result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
currentPage + 1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
async function getTransactionsByAccountId(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
pageSize: number,
|
||||
page: number,
|
||||
): Promise<{ results: unknown[]; totalPages: number; page: number }> {
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}),
|
||||
);
|
||||
transactions.results =
|
||||
mappedResults as unknown as typeof transactions.results;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
function getDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function flattenObject(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = '',
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(
|
||||
result,
|
||||
flattenObject(value as Record<string, unknown>, newKey),
|
||||
);
|
||||
} else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPayeeName(trans: Record<string, unknown>): string {
|
||||
const merchant = trans.merchant as Record<string, string> | undefined;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
|
||||
const paymentData = trans.paymentData as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver as Record<string, unknown>;
|
||||
const docNum = receiverData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (receiverData.name as string) || docNum?.value || '';
|
||||
}
|
||||
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer as Record<string, unknown>;
|
||||
const docNum = payerData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (payerData.name as string) || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
43
packages/bank-sync-plugin-pluggy.ai/src/manifest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
28
packages/bank-sync-plugin-pluggy.ai/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/bank-sync-plugin-simplefin/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.zip
|
||||
*.log
|
||||
159
packages/bank-sync-plugin-simplefin/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# SimpleFIN Bank Sync Plugin
|
||||
|
||||
A bank synchronization plugin for Actual Budget that connects to financial institutions via SimpleFIN.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin enables Actual Budget to sync bank account data and transactions through the SimpleFIN API. SimpleFIN provides a unified interface to connect with various financial institutions.
|
||||
|
||||
## Features
|
||||
|
||||
- Account discovery and synchronization
|
||||
- Transaction import with proper categorization
|
||||
- Support for pending and posted transactions
|
||||
- Balance information retrieval
|
||||
- Error handling for connection issues
|
||||
|
||||
## Installation
|
||||
|
||||
1. Build the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Install the plugin to your sync-server:
|
||||
```bash
|
||||
npm run install:plugin
|
||||
```
|
||||
|
||||
3. Restart your sync-server to load the plugin.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin requires a SimpleFIN access token to authenticate with the SimpleFIN API.
|
||||
|
||||
### Getting a SimpleFIN Token
|
||||
|
||||
1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/auth/login)
|
||||
2. Sign up for an account
|
||||
3. Connect your financial institutions
|
||||
4. Generate an access token
|
||||
|
||||
### Plugin Setup
|
||||
|
||||
Once the plugin is installed, configure it in Actual Budget by providing your SimpleFIN token when prompted during the bank connection setup.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /status
|
||||
Check if the plugin is configured with valid credentials.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"configured": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /accounts
|
||||
Fetch available accounts from connected financial institutions.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"token": "your-simplefin-token" // optional, will be saved if provided
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "123456789",
|
||||
"name": "Checking Account",
|
||||
"institution": "Bank Name",
|
||||
"balance": 1234.56,
|
||||
"mask": "6789",
|
||||
"official_name": "Premium Checking",
|
||||
"orgDomain": "bank.com",
|
||||
"orgId": "BANK123"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /transactions
|
||||
Fetch transactions for specific accounts within a date range.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"accountId": "123456789",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"balances": [
|
||||
{
|
||||
"balanceAmount": {
|
||||
"amount": "1234.56",
|
||||
"currency": "USD"
|
||||
},
|
||||
"balanceType": "expected",
|
||||
"referenceDate": "2024-01-15"
|
||||
}
|
||||
],
|
||||
"startingBalance": 123456,
|
||||
"transactions": {
|
||||
"all": [...],
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The plugin provides detailed error messages for various failure scenarios:
|
||||
|
||||
- `INVALID_ACCESS_TOKEN`: Invalid or expired SimpleFIN token
|
||||
- `SERVER_DOWN`: Communication issues with SimpleFIN
|
||||
- `ACCOUNT_MISSING`: Specified account not found
|
||||
- `ACCOUNT_NEEDS_ATTENTION`: Account requires attention on SimpleFIN Bridge
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build # Full build (compile + bundle + manifest + zip)
|
||||
npm run build:compile # TypeScript compilation only
|
||||
npm run build:bundle # Bundle with dependencies
|
||||
npm run build:manifest # Generate manifest.json
|
||||
npm run build:zip # Create distribution zip
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The plugin integrates with Actual Budget's existing test infrastructure. Run tests from the monorepo root:
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
45
packages/bank-sync-plugin-simplefin/manifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "simplefin-bank-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "SimpleFIN bank synchronization plugin for Actual Budget",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Check SimpleFIN configuration status"
|
||||
},
|
||||
{
|
||||
"path": "/accounts",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch accounts from SimpleFIN"
|
||||
},
|
||||
{
|
||||
"path": "/transactions",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch transactions from SimpleFIN"
|
||||
}
|
||||
],
|
||||
"bankSync": {
|
||||
"enabled": true,
|
||||
"displayName": "SimpleFIN",
|
||||
"description": "Connect your bank accounts via SimpleFIN",
|
||||
"requiresAuth": true,
|
||||
"endpoints": {
|
||||
"status": "/status",
|
||||
"accounts": "/accounts",
|
||||
"transactions": "/transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-simplefin/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@actual-app/bank-sync-plugin-simplefin",
|
||||
"version": "0.0.1",
|
||||
"description": "SimpleFIN bank sync plugin for Actual Budget",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
|
||||
"build:compile": "tsc",
|
||||
"build:bundle": "node scripts/build-bundle.cjs",
|
||||
"build:manifest": "node scripts/build-manifest.cjs",
|
||||
"build:zip": "node scripts/build-zip.cjs",
|
||||
"deploy": "npm run build && npm run install:plugin",
|
||||
"install:plugin": "node scripts/install-plugin.cjs",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"bank-sync",
|
||||
"simplefin"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"archiver": "^7.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/plugins-core-sync-server": "workspace:*",
|
||||
"axios": "^1.6.0",
|
||||
"express": "^4.18.0"
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-simplefin/scripts/build-bundle.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to bundle the plugin with all dependencies
|
||||
* Uses esbuild to create a single self-contained JavaScript file
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const { join } = require('path');
|
||||
|
||||
async function bundle() {
|
||||
try {
|
||||
console.log('Bundling plugin with dependencies...');
|
||||
|
||||
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
|
||||
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: ['express', 'axios'],
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
console.log('Bundle created successfully');
|
||||
console.log(`Output: dist/bundle.js`);
|
||||
} catch (error) {
|
||||
console.error('Failed to bundle:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bundle();
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to convert TypeScript manifest to JSON
|
||||
* This script imports the manifest.ts file and writes it as JSON to manifest.json
|
||||
*/
|
||||
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Import the manifest from the built TypeScript file
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
async function importManifest() {
|
||||
// First try to import from the compiled JavaScript
|
||||
try {
|
||||
const manifestModule = await import('../dist/manifest.js');
|
||||
return manifestModule.manifest;
|
||||
} catch (error) {
|
||||
console.error('Could not import compiled manifest:', error.message);
|
||||
console.log(
|
||||
'Make sure TypeScript is compiled first. Run: npm run build:compile',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest() {
|
||||
try {
|
||||
console.log('Building manifest.json...');
|
||||
|
||||
// Import the manifest from the compiled TypeScript
|
||||
const manifest = await importManifest();
|
||||
|
||||
// Convert to JSON with pretty formatting
|
||||
const jsonContent = JSON.stringify(manifest, null, 2);
|
||||
|
||||
// Write to manifest.json in the root directory
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
writeFileSync(manifestPath, jsonContent + '\n');
|
||||
|
||||
console.log('manifest.json created successfully');
|
||||
console.log(`Package: ${manifest.name}@${manifest.version}`);
|
||||
console.log(`Description: ${manifest.description}`);
|
||||
console.log(`Entry point: ${manifest.entry}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
104
packages/bank-sync-plugin-simplefin/scripts/build-zip.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to create a plugin distribution zip file
|
||||
* Creates: {packageName}.{version}.zip containing dist/index.js, manifest.json, and package.json
|
||||
*/
|
||||
|
||||
const { createWriteStream, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// Import package.json to get name and version
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
function importPackageJson() {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
return packageJson;
|
||||
} catch (error) {
|
||||
console.error('Could not import package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZip() {
|
||||
try {
|
||||
console.log('Creating plugin distribution zip...');
|
||||
|
||||
// Get package info
|
||||
const packageJson = importPackageJson();
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
|
||||
// Create zip filename
|
||||
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
|
||||
const zipPath = join(__dirname, '..', zipFilename);
|
||||
|
||||
console.log(`Creating ${zipFilename}`);
|
||||
|
||||
// Check if required files exist
|
||||
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.error('dist/bundle.js not found. Run: npm run build:bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error('manifest.json not found. Run: npm run build:manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create zip file
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }, // Maximum compression
|
||||
});
|
||||
|
||||
// Handle archive events
|
||||
archive.on('error', err => {
|
||||
console.error('Archive error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
archive.on('end', () => {
|
||||
const stats = archive.pointer();
|
||||
console.log(`${zipFilename} created successfully`);
|
||||
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
|
||||
console.log(
|
||||
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
|
||||
);
|
||||
});
|
||||
|
||||
// Pipe archive to file
|
||||
archive.pipe(output);
|
||||
|
||||
// Create package.json for the plugin with runtime dependencies
|
||||
const pluginPackageJson = {
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
express: packageJson.dependencies.express,
|
||||
axios: packageJson.dependencies.axios,
|
||||
},
|
||||
};
|
||||
const pluginPackageJsonContent = JSON.stringify(
|
||||
pluginPackageJson,
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Add files to archive
|
||||
archive.file(bundlePath, { name: 'index.js' });
|
||||
archive.file(manifestPath, { name: 'manifest.json' });
|
||||
archive.append(pluginPackageJsonContent, { name: 'package.json' });
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Failed to create zip:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createZip();
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
const pluginName = packageName.replace('@', '').replace('/', '-');
|
||||
const zipFileName = `${pluginName}.${version}.zip`;
|
||||
|
||||
// Source: built zip in package root (not in dist/)
|
||||
const sourceZip = path.join(__dirname, '..', zipFileName);
|
||||
|
||||
// Target: sync-server plugins directory
|
||||
// Go up to monorepo root, then to sync-server
|
||||
const targetDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'sync-server',
|
||||
'server-files',
|
||||
'plugins',
|
||||
);
|
||||
const targetZip = path.join(targetDir, zipFileName);
|
||||
|
||||
console.log('📦 Installing plugin to sync-server...');
|
||||
console.log(` Source: ${sourceZip}`);
|
||||
console.log(` Target: ${targetZip}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(sourceZip)) {
|
||||
console.error(`Error: ZIP file not found at ${sourceZip}`);
|
||||
console.error(' Run "npm run build" first to create the ZIP file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create target directory if it doesn't exist
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(`Creating plugins directory: ${targetDir}`);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove old versions of this plugin
|
||||
try {
|
||||
const files = fs.readdirSync(targetDir);
|
||||
const oldVersions = files.filter(
|
||||
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
|
||||
);
|
||||
|
||||
for (const oldFile of oldVersions) {
|
||||
const oldPath = path.join(targetDir, oldFile);
|
||||
console.log(` Removing old version: ${oldFile}`);
|
||||
fs.unlinkSync(oldPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` Warning: Could not clean old versions: ${err.message}`);
|
||||
}
|
||||
|
||||
// Copy the new ZIP
|
||||
try {
|
||||
fs.copyFileSync(sourceZip, targetZip);
|
||||
console.log(` Plugin installed successfully!`);
|
||||
console.log(` Location: ${targetZip}`);
|
||||
console.log('');
|
||||
console.log(' Restart your sync-server to load the plugin.');
|
||||
} catch (err) {
|
||||
console.error(` Error copying file: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
562
packages/bank-sync-plugin-simplefin/src/index.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
BankSyncErrorCode,
|
||||
BankSyncError,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
|
||||
// Type definitions for SimpleFIN account structure
|
||||
type SimpleFINAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: string;
|
||||
currency: string;
|
||||
'balance-date': number;
|
||||
org: {
|
||||
name: string;
|
||||
domain?: string;
|
||||
};
|
||||
transactions: SimpleFINTransaction[];
|
||||
};
|
||||
|
||||
type SimpleFINTransaction = {
|
||||
id: string;
|
||||
payee: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
transacted_at?: number;
|
||||
posted?: number;
|
||||
pending?: boolean | number;
|
||||
};
|
||||
|
||||
type SimpleFINResponse = {
|
||||
accounts: SimpleFINAccount[];
|
||||
errors: string[];
|
||||
sferrors: string[];
|
||||
hasError: boolean;
|
||||
accountErrors?: Record<string, any[]>;
|
||||
};
|
||||
|
||||
type ParsedAccessKey = {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
/**
|
||||
* POST /status
|
||||
* Check if SimpleFIN is configured
|
||||
*/
|
||||
app.post('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const tokenResult = await getSecret(req, 'simplefin_token');
|
||||
const configured = tokenResult.value != null && tokenResult.value !== 'Forbidden';
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from SimpleFIN
|
||||
* Body: { token?: string }
|
||||
*
|
||||
* If token is provided, it will be saved as a secret
|
||||
*/
|
||||
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
// If token is provided in request, save it
|
||||
if (token) {
|
||||
await saveSecret(req, 'simplefin_token', token);
|
||||
}
|
||||
|
||||
let accessKey: string | null = null;
|
||||
|
||||
try {
|
||||
const tokenResult = await getSecret(req, 'simplefin_token');
|
||||
const storedToken = tokenResult.value;
|
||||
|
||||
if (storedToken == null || storedToken === 'Forbidden') {
|
||||
throw new Error('No token');
|
||||
} else {
|
||||
accessKey = await getAccessKey(storedToken);
|
||||
await saveSecret(req, 'simplefin_accessKey', accessKey);
|
||||
if (accessKey == null || accessKey === 'Forbidden') {
|
||||
throw new Error('No access key');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: 'INVALID_ACCESS_TOKEN',
|
||||
error_code: 'INVALID_ACCESS_TOKEN',
|
||||
status: 'rejected',
|
||||
reason:
|
||||
'Invalid SimpleFIN access token. Reset the token and re-link any broken accounts.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await getAccounts(accessKey, null, null, null, true);
|
||||
|
||||
// Transform SimpleFIN accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.accounts.map((account: SimpleFINAccount) => ({
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution: account.org.name,
|
||||
balance: parseFloat(account.balance.replace('.', '')) / 100,
|
||||
mask: account.id.substring(account.id.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain: account.org.domain || null,
|
||||
orgId: account.org.name,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[SIMPLEFIN ACCOUNTS] Error:', e);
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.SERVER_ERROR,
|
||||
error_code: BankSyncErrorCode.SERVER_ERROR,
|
||||
status: 'error',
|
||||
reason: 'There was an error communicating with SimpleFIN.',
|
||||
};
|
||||
|
||||
if (e instanceof Error) {
|
||||
const errorMessage = e.message.toLowerCase();
|
||||
|
||||
if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.reason = 'Invalid SimpleFIN access token. Please reconfigure your connection.';
|
||||
} else if (errorMessage.includes('401') || errorMessage.includes('unauthorized')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.reason = 'Unauthorized access to SimpleFIN. Please check your credentials.';
|
||||
} else if (errorMessage.includes('network') || errorMessage.includes('econnrefused') || errorMessage.includes('enotfound')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.reason = 'Network error communicating with SimpleFIN. Please check your connection.';
|
||||
}
|
||||
|
||||
errorResponse.details = { originalError: e.message };
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from SimpleFIN
|
||||
* Body: { accountId: string, startDate: string, token?: string }
|
||||
*/
|
||||
app.post('/transactions', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body || {};
|
||||
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const accessKeyResult = await getSecret(req, 'simplefin_accessKey');
|
||||
|
||||
if (accessKeyResult.value == null || accessKeyResult.value === 'Forbidden') {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: 'INVALID_ACCESS_TOKEN',
|
||||
error_code: 'INVALID_ACCESS_TOKEN',
|
||||
status: 'rejected',
|
||||
reason:
|
||||
'Invalid SimpleFIN access token. Reset the token and re-link any broken accounts.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(accountId) !== Array.isArray(startDate)) {
|
||||
console.log({ accountId, startDate });
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId and startDate must either both be arrays or both be strings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(accountId) && accountId.length !== startDate.length) {
|
||||
console.log({ accountId, startDate });
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId and startDate arrays must be the same length',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestStartDate = Array.isArray(startDate)
|
||||
? startDate.reduce((a, b) => (a < b ? a : b))
|
||||
: startDate;
|
||||
|
||||
let results: SimpleFINResponse;
|
||||
try {
|
||||
results = await getTransactions(
|
||||
accessKeyResult.value,
|
||||
Array.isArray(accountId) ? accountId : [accountId],
|
||||
new Date(earliestStartDate),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[SIMPLEFIN TRANSACTIONS] Error:', e);
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.SERVER_ERROR,
|
||||
error_code: BankSyncErrorCode.SERVER_ERROR,
|
||||
status: 'error',
|
||||
reason: 'There was an error communicating with SimpleFIN.',
|
||||
};
|
||||
|
||||
if (e instanceof Error) {
|
||||
const errorMessage = e.message.toLowerCase();
|
||||
|
||||
if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_ACCESS_TOKEN;
|
||||
errorResponse.reason = 'Invalid SimpleFIN access token. Please reconfigure your connection.';
|
||||
} else if (errorMessage.includes('401') || errorMessage.includes('unauthorized')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.reason = 'Unauthorized access to SimpleFIN. Please check your credentials.';
|
||||
} else if (errorMessage.includes('404') || errorMessage.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.reason = 'Account not found in SimpleFIN. Please check your account configuration.';
|
||||
} else if (errorMessage.includes('network') || errorMessage.includes('econnrefused') || errorMessage.includes('enotfound')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.reason = 'Network error communicating with SimpleFIN. Please check your connection.';
|
||||
}
|
||||
|
||||
errorResponse.details = { originalError: e.message };
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let response: any = {};
|
||||
if (Array.isArray(accountId)) {
|
||||
for (let i = 0; i < accountId.length; i++) {
|
||||
const id = accountId[i];
|
||||
response[id] = getAccountResponse(results, id, new Date(startDate[i]));
|
||||
}
|
||||
} else {
|
||||
response = getAccountResponse(results, accountId, new Date(startDate));
|
||||
}
|
||||
|
||||
if (results.hasError) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: !Array.isArray(accountId)
|
||||
? (results.accountErrors?.[accountId]?.[0] || results.errors[0])
|
||||
: {
|
||||
...response,
|
||||
errors: results.accountErrors || results.errors,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: response,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function logAccountError(results: SimpleFINResponse, accountId: string, data: any) {
|
||||
// For account-specific errors, we store them in the results object for later retrieval
|
||||
if (!results.accountErrors) {
|
||||
results.accountErrors = {};
|
||||
}
|
||||
const errors = results.accountErrors[accountId] || [];
|
||||
errors.push(data);
|
||||
results.accountErrors[accountId] = errors;
|
||||
results.hasError = true;
|
||||
}
|
||||
|
||||
function getAccountResponse(results: SimpleFINResponse, accountId: string, startDate: Date): any {
|
||||
const account = !results?.accounts ? undefined : results.accounts.find(a => a.id === accountId);
|
||||
if (!account) {
|
||||
console.log(
|
||||
`The account "${accountId}" was not found. Here were the accounts returned:`,
|
||||
);
|
||||
if (results?.accounts) {
|
||||
results.accounts.forEach(a => console.log(`${a.id} - ${a.org.name}`));
|
||||
}
|
||||
logAccountError(results, accountId, {
|
||||
error_type: 'ACCOUNT_MISSING',
|
||||
error_code: 'ACCOUNT_MISSING',
|
||||
reason: `The account "${accountId}" was not found. Try unlinking and relinking the account.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const needsAttention = results.sferrors.find(e =>
|
||||
e.startsWith(`Connection to ${account.org.name} may need attention`),
|
||||
);
|
||||
if (needsAttention) {
|
||||
logAccountError(results, accountId, {
|
||||
error_type: 'ACCOUNT_NEEDS_ATTENTION',
|
||||
error_code: 'ACCOUNT_NEEDS_ATTENTION',
|
||||
reason:
|
||||
'The account needs your attention at <a href="https://bridge.simplefin.org/auth/login">SimpleFIN</a>.',
|
||||
});
|
||||
}
|
||||
|
||||
const startingBalance = parseInt(account.balance.replace('.', ''));
|
||||
const date = getDate(new Date(account['balance-date'] * 1000));
|
||||
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
},
|
||||
balanceType: 'interimAvailable',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
|
||||
const all: any[] = [];
|
||||
const booked: any[] = [];
|
||||
const pending: any[] = [];
|
||||
|
||||
for (const trans of account.transactions) {
|
||||
const newTrans: any = {};
|
||||
|
||||
let dateToUse = 0;
|
||||
|
||||
if (trans.pending ?? trans.posted === 0) {
|
||||
newTrans.booked = false;
|
||||
dateToUse = trans.transacted_at || 0;
|
||||
} else {
|
||||
newTrans.booked = true;
|
||||
dateToUse = trans.posted || 0;
|
||||
}
|
||||
|
||||
const transactionDate = new Date(dateToUse * 1000);
|
||||
|
||||
if (transactionDate < startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrans.sortOrder = dateToUse;
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = trans.payee;
|
||||
newTrans.notes = trans.description;
|
||||
newTrans.transactionAmount = { amount: trans.amount, currency: 'USD' };
|
||||
newTrans.transactionId = trans.id;
|
||||
newTrans.valueDate = newTrans.bookingDate;
|
||||
|
||||
if (trans.transacted_at) {
|
||||
newTrans.transactedDate = getDate(new Date(trans.transacted_at * 1000));
|
||||
}
|
||||
|
||||
if (trans.posted) {
|
||||
newTrans.postedDate = getDate(new Date(trans.posted * 1000));
|
||||
}
|
||||
|
||||
if (newTrans.booked) {
|
||||
booked.push(newTrans);
|
||||
} else {
|
||||
pending.push(newTrans);
|
||||
}
|
||||
all.push(newTrans);
|
||||
}
|
||||
|
||||
const sortFunction = (a: any, b: any) => b.sortOrder - a.sortOrder;
|
||||
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
|
||||
return {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAccessKey(accessKey: string): ParsedAccessKey {
|
||||
if (!accessKey || !accessKey.match(/^.*\/\/.*:.*@.*$/)) {
|
||||
console.log(`Invalid SimpleFIN access key: ${accessKey}`);
|
||||
throw new Error(`Invalid access key`);
|
||||
}
|
||||
const [scheme, rest] = accessKey.split('//');
|
||||
const [auth, restAfterAuth] = rest.split('@');
|
||||
const [username, password] = auth.split(':');
|
||||
const baseUrl = `${scheme}//${restAfterAuth}`;
|
||||
return {
|
||||
baseUrl,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAccessKey(base64Token: string): Promise<string> {
|
||||
const token = Buffer.from(base64Token, 'base64').toString();
|
||||
|
||||
const response = await axios.post(token, undefined, {
|
||||
headers: { 'Content-Length': 0 },
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getTransactions(
|
||||
accessKey: string,
|
||||
accounts: string[],
|
||||
startDate: Date,
|
||||
endDate?: Date,
|
||||
): Promise<SimpleFINResponse> {
|
||||
const now = new Date();
|
||||
startDate = startDate || new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
endDate = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
console.log(`${getDate(startDate)} - ${getDate(endDate)}`);
|
||||
return await getAccounts(accessKey, accounts, startDate, endDate);
|
||||
}
|
||||
|
||||
function getDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function normalizeDate(date: Date): number {
|
||||
return (date.valueOf() - date.getTimezoneOffset() * 60 * 1000) / 1000;
|
||||
}
|
||||
|
||||
async function getAccounts(
|
||||
accessKey: string,
|
||||
accounts?: string[] | null,
|
||||
startDate?: Date | null,
|
||||
endDate?: Date | null,
|
||||
noTransactions = false,
|
||||
): Promise<SimpleFINResponse> {
|
||||
const sfin = parseAccessKey(accessKey);
|
||||
|
||||
const headers = {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${sfin.username}:${sfin.password}`,
|
||||
).toString('base64')}`,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (!noTransactions) {
|
||||
if (startDate) {
|
||||
params.append('start-date', normalizeDate(startDate).toString());
|
||||
}
|
||||
if (endDate) {
|
||||
params.append('end-date', normalizeDate(endDate).toString());
|
||||
}
|
||||
params.append('pending', '1');
|
||||
} else {
|
||||
params.append('balances-only', '1');
|
||||
}
|
||||
|
||||
if (accounts) {
|
||||
for (const id of accounts) {
|
||||
params.append('account', id);
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(`${sfin.baseUrl}/accounts`);
|
||||
url.search = params.toString();
|
||||
|
||||
const response = await axios.get(url.toString(), {
|
||||
headers,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new Error('Forbidden');
|
||||
}
|
||||
|
||||
// axios automatically parses JSON, so response.data is already an object
|
||||
const results: SimpleFINResponse = response.data as SimpleFINResponse;
|
||||
results.sferrors = results.errors;
|
||||
results.hasError = false;
|
||||
results.errors = [];
|
||||
results.accountErrors = {};
|
||||
return results;
|
||||
}
|
||||
|
||||
console.log('SimpleFIN Bank Sync Plugin loaded');
|
||||
43
packages/bank-sync-plugin-simplefin/src/manifest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'simplefin-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'SimpleFIN bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check SimpleFIN configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from SimpleFIN',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from SimpleFIN',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'SimpleFIN',
|
||||
description: 'Connect your bank accounts via SimpleFIN',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
28
packages/bank-sync-plugin-simplefin/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
73
packages/ci-actions/bin/get-next-package-version.js
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { getNextVersion } from '../src/versions/get-next-package-version.js';
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
},
|
||||
type: {
|
||||
type: 'string', // nightly, hotfix, monthly, auto
|
||||
short: 't',
|
||||
},
|
||||
update: {
|
||||
type: 'boolean',
|
||||
short: 'u',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
let newVersion;
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(newVersion);
|
||||
|
||||
if (values.update) {
|
||||
packageJson.version = newVersion;
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
11
packages/ci-actions/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@actual-app/ci-actions",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest"
|
||||
}
|
||||
}
|
||||
72
packages/ci-actions/src/versions/get-next-package-version.js
Normal file
@@ -0,0 +1,72 @@
|
||||
function parseVersion(version) {
|
||||
const [y, m, p] = version.split('.');
|
||||
return {
|
||||
versionYear: parseInt(y, 10),
|
||||
versionMonth: parseInt(m, 10),
|
||||
versionHotfix: parseInt(p, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function computeNextMonth(versionYear, versionMonth) {
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const fullYear = nextVersionMonthDate.getFullYear();
|
||||
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
return { nextVersionYear, nextVersionMonth };
|
||||
}
|
||||
|
||||
// Determine logical type from 'auto' based on the current date and version
|
||||
function resolveType(type, currentDate, versionYear, versionMonth) {
|
||||
if (type !== 'auto') return type;
|
||||
const inPatchMonth =
|
||||
currentDate.getFullYear() === 2000 + versionYear &&
|
||||
currentDate.getMonth() + 1 === versionMonth;
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
export function getNextVersion({
|
||||
currentVersion,
|
||||
type,
|
||||
currentDate = new Date(),
|
||||
}) {
|
||||
const { versionYear, versionMonth, versionHotfix } =
|
||||
parseVersion(currentVersion);
|
||||
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
|
||||
versionYear,
|
||||
versionMonth,
|
||||
);
|
||||
const resolvedType = resolveType(
|
||||
type,
|
||||
currentDate,
|
||||
versionYear,
|
||||
versionMonth,
|
||||
);
|
||||
|
||||
// Format date stamp once for nightly
|
||||
const currentDateString = currentDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (resolvedType) {
|
||||
case 'nightly':
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDateString}`;
|
||||
case 'hotfix':
|
||||
return `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
|
||||
case 'monthly':
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
default:
|
||||
throw new Error(
|
||||
'Invalid type specified. Use “auto”, “nightly”, “hotfix”, or “monthly”.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getNextVersion } from './get-next-package-version';
|
||||
|
||||
describe('getNextVersion (lib)', () => {
|
||||
it('hotfix increments patch', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.1',
|
||||
type: 'hotfix',
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toBe('25.8.2');
|
||||
});
|
||||
|
||||
it('monthly advances month same year', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.3',
|
||||
type: 'monthly',
|
||||
currentDate: new Date('2025-08-15'),
|
||||
}),
|
||||
).toBe('25.9.0');
|
||||
});
|
||||
|
||||
it('monthly wraps year December -> January', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.12.3',
|
||||
type: 'monthly',
|
||||
currentDate: new Date('2025-12-05'),
|
||||
}),
|
||||
).toBe('26.1.0');
|
||||
});
|
||||
|
||||
it('nightly format with date stamp', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.1',
|
||||
type: 'nightly',
|
||||
currentDate: new Date('2025-08-22'),
|
||||
}),
|
||||
).toBe('25.9.0-nightly.20250822');
|
||||
});
|
||||
|
||||
it('auto before 25th -> hotfix', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'auto',
|
||||
currentDate: new Date('2025-08-20'),
|
||||
}),
|
||||
).toBe('25.8.5');
|
||||
});
|
||||
|
||||
it('auto after 25th (same month) -> monthly', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'auto',
|
||||
currentDate: new Date('2025-08-27'),
|
||||
}),
|
||||
).toBe('25.9.0');
|
||||
});
|
||||
|
||||
it('auto after 25th (next month) -> monthly', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'auto',
|
||||
currentDate: new Date('2025-09-02'),
|
||||
}),
|
||||
).toBe('25.9.0');
|
||||
});
|
||||
|
||||
it('invalid type throws', () => {
|
||||
expect(() =>
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'unknown',
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toThrow(/Invalid type/);
|
||||
});
|
||||
});
|
||||
9
packages/ci-actions/vitest.config.mts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -8,14 +8,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"react-aria-components": "^1.8.0",
|
||||
"react-aria-components": "^1.13.0",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"exports": {
|
||||
@@ -48,7 +48,8 @@
|
||||
"./tokens": "./src/tokens.ts",
|
||||
"./toggle": "./src/Toggle.tsx",
|
||||
"./tooltip": "./src/Tooltip.tsx",
|
||||
"./view": "./src/View.tsx"
|
||||
"./view": "./src/View.tsx",
|
||||
"./color-picker": "./src/ColorPicker.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
|
||||
180
packages/component-library/src/ColorPicker.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ChangeEvent, ReactNode } from 'react';
|
||||
import {
|
||||
ColorPicker as AriaColorPicker,
|
||||
ColorPickerProps as AriaColorPickerProps,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
ColorSwatchProps,
|
||||
ColorSwatchPicker as AriaColorSwatchPicker,
|
||||
ColorSwatchPickerItem,
|
||||
ColorField,
|
||||
parseColor,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { Popover } from './Popover';
|
||||
|
||||
function ColorSwatch(props: ColorSwatchProps) {
|
||||
return (
|
||||
<AriaColorSwatch
|
||||
{...props}
|
||||
style={({ color }) => ({
|
||||
background: color.toString('hex'),
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '4px',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// colors from https://materialui.co/colors
|
||||
const DEFAULT_COLOR_SET = [
|
||||
'#690CB0',
|
||||
'#D32F2F',
|
||||
'#C2185B',
|
||||
'#7B1FA2',
|
||||
'#512DA8',
|
||||
'#303F9F',
|
||||
'#1976D2',
|
||||
'#0288D1',
|
||||
'#0097A7',
|
||||
'#00796B',
|
||||
'#388E3C',
|
||||
'#689F38',
|
||||
'#AFB42B',
|
||||
'#FBC02D',
|
||||
'#FFA000',
|
||||
'#F57C00',
|
||||
'#E64A19',
|
||||
'#5D4037',
|
||||
'#616161',
|
||||
'#455A64',
|
||||
];
|
||||
|
||||
interface ColorSwatchPickerProps {
|
||||
columns?: number;
|
||||
colorset?: string[];
|
||||
}
|
||||
|
||||
function ColorSwatchPicker({
|
||||
columns = 5,
|
||||
colorset = DEFAULT_COLOR_SET,
|
||||
}: ColorSwatchPickerProps) {
|
||||
const pickers = [];
|
||||
|
||||
for (let l = 0; l < colorset.length / columns; l++) {
|
||||
const pickerItems = [];
|
||||
|
||||
for (let c = 0; c < columns; c++) {
|
||||
const color = colorset[columns * l + c];
|
||||
if (!color) {
|
||||
break;
|
||||
}
|
||||
|
||||
pickerItems.push(
|
||||
<ColorSwatchPickerItem
|
||||
key={color}
|
||||
color={color}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
width: 'fit-content',
|
||||
forcedColorAdjust: 'none',
|
||||
cursor: 'pointer',
|
||||
|
||||
'&[data-selected]::after': {
|
||||
// eslint-disable-next-line actual/typography
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: '2px solid black',
|
||||
outline: '2px solid white',
|
||||
outlineOffset: '-4px',
|
||||
borderRadius: 'inherit',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ColorSwatch />
|
||||
</ColorSwatchPickerItem>,
|
||||
);
|
||||
}
|
||||
|
||||
pickers.push(
|
||||
<AriaColorSwatchPicker
|
||||
key={`colorset-${l}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{pickerItems}
|
||||
</AriaColorSwatchPicker>,
|
||||
);
|
||||
}
|
||||
|
||||
return pickers;
|
||||
}
|
||||
const isColor = (value: string) => /^#[0-9a-fA-F]{6}$/.test(value);
|
||||
|
||||
interface ColorPickerProps extends AriaColorPickerProps {
|
||||
children?: ReactNode;
|
||||
columns?: number;
|
||||
colorset?: string[];
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
children,
|
||||
columns,
|
||||
colorset,
|
||||
...props
|
||||
}: ColorPickerProps) {
|
||||
const onInput = (value: string) => {
|
||||
if (!isColor(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const color = parseColor(value);
|
||||
if (color) {
|
||||
props.onChange?.(color);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaColorPicker defaultValue={props.defaultValue ?? '#690CB0'} {...props}>
|
||||
<DialogTrigger>
|
||||
{children}
|
||||
<Popover>
|
||||
<Dialog
|
||||
style={{
|
||||
outline: 'none',
|
||||
padding: '15px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
minWidth: '192px',
|
||||
maxHeight: 'inherit',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<ColorSwatchPicker columns={columns} colorset={colorset} />
|
||||
<ColorField
|
||||
onInput={({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
|
||||
onInput(value)
|
||||
}
|
||||
>
|
||||
<Input placeholder="#RRGGBB" style={{ width: '100px' }} />
|
||||
</ColorField>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</AriaColorPicker>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { type CSSProperties, type ReactNode } from 'react';
|
||||
import React, { type ReactNode } from 'react';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import { View } from './View';
|
||||
|
||||
type SpaceBetweenProps = {
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Toggle = ({
|
||||
data-on={isOn}
|
||||
className={css(
|
||||
{
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
// eslint-disable-next-line actual/typography
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
|
||||
@@ -27,8 +27,13 @@ export const Tooltip = ({
|
||||
const [isHovered, setIsHover] = useState(false);
|
||||
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsHover(true);
|
||||
}, triggerProps.delay ?? 300);
|
||||
@@ -41,8 +46,10 @@ export const Tooltip = ({
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsHover(false);
|
||||
}, []);
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setIsHover(false);
|
||||
}, triggerProps.closeDelay ?? 0);
|
||||
}, [triggerProps.closeDelay]);
|
||||
|
||||
// Force closing the tooltip whenever the disablement state changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -23,7 +23,11 @@ export const View = forwardRef<HTMLDivElement, ViewProps>((props, ref) => {
|
||||
{...restProps}
|
||||
ref={innerRef ?? ref}
|
||||
style={nativeStyle}
|
||||
className={cx('view', className, css(style))}
|
||||
className={cx(
|
||||
'view',
|
||||
className,
|
||||
style && Object.keys(style).length > 0 ? css(style) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ export const styles: Record<string, any> = {
|
||||
},
|
||||
shadowLarge,
|
||||
tnum: {
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
// eslint-disable-next-line actual/typography
|
||||
fontFeatureSettings: '"tnum"',
|
||||
},
|
||||
notFixed: { fontFeatureSettings: '' },
|
||||
@@ -154,4 +154,10 @@ export const styles: Record<string, any> = {
|
||||
borderRadius: 4,
|
||||
padding: '3px 5px',
|
||||
},
|
||||
mobileListItem: {
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
backgroundColor: theme.tableBackground,
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -188,6 +188,7 @@ export const theme = {
|
||||
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
|
||||
noteTagBackground: 'var(--color-noteTagBackground)',
|
||||
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
|
||||
noteTagDefault: 'var(--color-noteTagDefault)',
|
||||
noteTagText: 'var(--color-noteTagText)',
|
||||
budgetOtherMonth: 'var(--color-budgetOtherMonth)',
|
||||
budgetCurrentMonth: 'var(--color-budgetCurrentMonth)',
|
||||
|
||||
@@ -18,4 +18,9 @@ protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
|
||||
|
||||
../../node_modules/.bin/prettier --write src/proto/*.d.ts
|
||||
|
||||
for file in src/proto/*.d.ts; do
|
||||
{ echo "/* eslint-disable @typescript-eslint/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
|
||||
rm "$file"
|
||||
done
|
||||
|
||||
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'
|
||||
|
||||
@@ -11,17 +11,19 @@
|
||||
"scripts": {
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node && cp src/proto/sync_pb.d.ts dist/src/proto/",
|
||||
"build": "rm -rf dist && yarn run build:node",
|
||||
"test": "vitest --globals"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "^3.15.12",
|
||||
"protoc-gen-js": "^3.21.4-4",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as SyncPb from './proto/sync_pb';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import './proto/sync_pb.js'; // Import for side effects
|
||||
|
||||
export {
|
||||
merkle,
|
||||
getClock,
|
||||
@@ -11,4 +13,11 @@ export {
|
||||
Timestamp,
|
||||
} from './crdt';
|
||||
|
||||
export const SyncProtoBuf = SyncPb;
|
||||
// Access global proto namespace
|
||||
export const SyncRequest = (globalThis as any).proto.SyncRequest;
|
||||
export const SyncResponse = (globalThis as any).proto.SyncResponse;
|
||||
export const Message = (globalThis as any).proto.Message;
|
||||
export const MessageEnvelope = (globalThis as any).proto.MessageEnvelope;
|
||||
export const EncryptedData = (globalThis as any).proto.EncryptedData;
|
||||
|
||||
export const SyncProtoBuf = (globalThis as any).proto;
|
||||
|
||||
@@ -157,9 +157,9 @@ proto.EncryptedData.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.EncryptedData.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
iv: msg.getIv_asB64(),
|
||||
authtag: msg.getAuthtag_asB64(),
|
||||
data: msg.getData_asB64()
|
||||
iv: msg.getIv_asB64(),
|
||||
authtag: msg.getAuthtag_asB64(),
|
||||
data: msg.getData_asB64()
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -419,10 +419,10 @@ proto.Message.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.Message.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
dataset: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
row: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
column: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
value: jspb.Message.getFieldWithDefault(msg, 4, "")
|
||||
dataset: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
row: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
column: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
value: jspb.Message.getFieldWithDefault(msg, 4, "")
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -639,9 +639,9 @@ proto.MessageEnvelope.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.MessageEnvelope.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
timestamp: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
isencrypted: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
content: msg.getContent_asB64()
|
||||
timestamp: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
isencrypted: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
content: msg.getContent_asB64()
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -860,12 +860,12 @@ proto.SyncRequest.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.SyncRequest.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
proto.MessageEnvelope.toObject, includeInstance),
|
||||
fileid: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
groupid: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
keyid: jspb.Message.getFieldWithDefault(msg, 5, ""),
|
||||
since: jspb.Message.getFieldWithDefault(msg, 6, "")
|
||||
fileid: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
groupid: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
keyid: jspb.Message.getFieldWithDefault(msg, 5, ""),
|
||||
since: jspb.Message.getFieldWithDefault(msg, 6, "")
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -1140,9 +1140,9 @@ proto.SyncResponse.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.SyncResponse.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
proto.MessageEnvelope.toObject, includeInstance),
|
||||
merkle: jspb.Message.getFieldWithDefault(msg, 2, "")
|
||||
merkle: jspb.Message.getFieldWithDefault(msg, 2, "")
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
// package:
|
||||
// file: sync.proto
|
||||
|
||||
import * as jspb from 'google-protobuf';
|
||||
|
||||
export class EncryptedData extends jspb.Message {
|
||||
export declare class EncryptedData extends jspb.Message {
|
||||
getIv(): Uint8Array | string;
|
||||
getIv_asU8(): Uint8Array;
|
||||
getIv_asB64(): string;
|
||||
@@ -48,7 +49,7 @@ export namespace EncryptedData {
|
||||
};
|
||||
}
|
||||
|
||||
export class Message extends jspb.Message {
|
||||
export declare class Message extends jspb.Message {
|
||||
getDataset(): string;
|
||||
setDataset(value: string): void;
|
||||
|
||||
@@ -88,7 +89,7 @@ export namespace Message {
|
||||
};
|
||||
}
|
||||
|
||||
export class MessageEnvelope extends jspb.Message {
|
||||
export declare class MessageEnvelope extends jspb.Message {
|
||||
getTimestamp(): string;
|
||||
setTimestamp(value: string): void;
|
||||
|
||||
@@ -129,7 +130,7 @@ export namespace MessageEnvelope {
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncRequest extends jspb.Message {
|
||||
export declare class SyncRequest extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
@@ -178,7 +179,7 @@ export namespace SyncRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncResponse extends jspb.Message {
|
||||
export declare class SyncResponse extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
3
packages/desktop-client/.gitignore
vendored
@@ -14,6 +14,9 @@ build-electron
|
||||
build-stats
|
||||
stats.json
|
||||
|
||||
# generated service worker
|
||||
service-worker/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -65,10 +65,10 @@ Run manually:
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
|
||||
|
||||
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
|
||||
|
||||
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
|
||||
# Use the ip and port noted earlier
|
||||
|
||||
@@ -9,6 +9,7 @@ rm -fr build
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
|
||||
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH=`ls "$ROOT"/../service-worker/plugin-sw.*.js | sed 's/.*plugin-sw\.\(.*\)\.js/\1/'`
|
||||
|
||||
yarn build
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@ cd "$ROOT/.."
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export PORT=3001
|
||||
export REACT_APP_BACKEND_WORKER_HASH="dev"
|
||||
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH="dev"
|
||||
|
||||
yarn start
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |