Compare commits

...

111 Commits

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

This enables the plugin architecture for bank sync while preserving existing functionality, ready for feature flag control.
2025-10-08 11:12:01 -03:00
Matiss Janis Aboltins
f7b40fca64 Add swipe to delete to mobile rules (#5871) 2025-10-07 20:33:46 +02:00
Stephen Brown II
dc811552be feat(currency): Currency-influenced initial number formats (#5797) 2025-10-07 19:05:16 +01:00
lelemm
295839ebbb 🐛 Fix for worker in dev mode (#5878)
* Fix for worker in dev mode

* Add release notes for PR #5878

* trigger actions

---------

Co-authored-by: Leandro Menezes <leandro.menezes@fusionflowsoftware.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-07 14:45:13 -03:00
Michael Süssemilch
99ca34458e feat(currency): add currency display to rules (#5639)
* feat(currency): add to rules

* doc: release notes

* feat: remove keydown from Input

* doc: release notes

* fix: make onEnter optional

* fix: ai remark

* refactor: remove onKeyDown from Input.tsx

* fix: handle Amount (inflow) and Amount (outflow) properly

* [autofix.ci] apply automated fixes

* fix: update AmountInput to sign and on outflow set +

* refactor: onSubmit handling of input value

* coderabbit suggestions

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-07 10:44:21 -07:00
lelemm
90ac8d8520 📚 More Translations (#5812)
* Translations

* linter

* Add release notes for PR #5812

* actions trigger

* md category change

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* typecheck fix

* linter

* more linter

* omg

* Fixes

* [autofix.ci] apply automated fixes

* Code review change

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Leandro Menezes <leandro.menezes@fusionflowsoftware.com>
2025-10-07 14:36:10 -03:00
Matt Fiddaman
52aeec2d59 ♻️ bump react dependencies (#5865) 2025-10-07 17:50:41 +01:00
lelemm
0c280d60f6 Frontend plugins Support [3/10]: System-Wide Feature Flag System + Frontend plugins feature flag (#5785)
Support for global feature flag and minimum custom theme prefs for plugins
2025-10-07 13:13:31 -03:00
Roque Alejandro Sosa
148ca92584 Added ARS currency (#5869)
* Added ARS currency

* Added correct release number

---------

Co-authored-by: Ras <git.boasting733@passinbox.com>
2025-10-07 08:28:38 -07:00
Ilyos Khurozov
90e848ebe8 Added support for Uzbek Soum (UZS) (#5876) 2025-10-07 08:16:43 -07:00
lelemm
b034d5039f Frontend plugins Support [2/10]: Plugin service worker (#5784)
* Plugin service worker
2025-10-07 12:14:32 -03:00
Matiss Janis Aboltins
5ac29473f2 Mobile payees - swipe to delete (#5824) 2025-10-06 19:23:52 +01:00
Matt Fiddaman
3b0db2bed7 ♻️ bump various build dependencies (#5864)
* vite 7.1.9

* typescript 5.9.3

* @types/node 22.18.8

* linting

* emscripten types

* note
2025-10-06 17:32:42 +01:00
Michael Clark
7a886810bc :electron: Hide the Electron menu (#5847)
* add retries to electron server import

* release notes

* get rid of this menu. If its an app functionality it should be available within the app

* hide the menu - update the ui

* fix function call

* Update VRT

* release notes

* spelling mistake

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-06 17:13:47 +01:00
Haritha Hasathcharu
8bf0997275 Add LKR and CRC currencies (#5848) 2025-10-06 08:41:59 -07:00
Matt Fiddaman
2f965266ab run schedule rules regardless of posted date (#5870)
* run schedule rules regardless of date

* note
2025-10-06 16:31:24 +01:00
Matt Fiddaman
499f24f7fd ♻️ bump non-react deps in desktop-client (#5858)
* patch/minor deps

* @vitejs/plugin-basic-ssl 2.1.0

* remove chokidar

* cross-env 10.1.0

* downshift 9.0.10

* remove focus-visible

* jsdom 27.0.0

* rollup-plugin-visualizer 6.0.4

* note
2025-10-06 16:28:04 +01:00
Matiss Janis Aboltins
4c5be62f56 Mobile payees - add loading indicator to rules count label (#5842) 2025-10-05 19:57:27 +01:00
Matiss Janis Aboltins
1446c7d93f Mobile rules - refactor to use react-aria GridList component (#5804) 2025-10-05 19:57:06 +01:00
Julian Dominguez-Schatz
ad9980307e Fix React compiler behaviour in dev mode (#5853)
* Fix React compiler behaviour in dev mode

* Add release notes

* Add comment
2025-10-05 07:14:03 -07:00
dependabot[bot]
d4ad31fb0c Bump tar-fs from 2.1.3 to 2.1.4 (#5796)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.3 to 2.1.4.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.3...v2.1.4)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-05 14:51:07 +01:00
Matt Fiddaman
05355788e4 ♻️ bump sync-server dependencies (#5819)
* uuid 11.1.0 -> 13.0.0

* better-sqlite3 12.2.0 -> 12.4.1

* debug 4.4.1 -> 4.4.3

* express-rate-limit 8.0.1 -> 8.1.0

* pluggy-sdk 0.74.0 -> 0.77.0

* babel/core 7.28.0 -> 7.28.4

* note
2025-10-05 14:36:57 +01:00
Stephen Brown II
805e2b1807 Align amount conversion utilities between api and loot-core (#5747)
* Align amount conversion utilities between api and loot-core

Updates api amount conversion utilities to align with loot-core, improving consistency and maintainability across the project.

Uses decimal places as parameters in conversion functions.

* Moves amount conversion utils to core

Moves amount conversion utilities to the core library.

This change consolidates these utilities for better code reuse
and maintainability across different parts of the application.
It removes the duplicate definition from the API package and
imports it from the core library where it is shared.
2025-10-05 14:23:34 +01:00
Çağdaş Şenel
e54dc0c1ca fix losing transaction amount decimals on update (#5807) 2025-10-05 14:23:15 +01:00
Çağdaş Şenel
e1c2f0a181 feat: show full decimals while editing (#5808)
* show full decimals while editing

* add changes

* handle null
2025-10-05 14:23:04 +01:00
Matt Fiddaman
cc2e329e8e show empty data points on line graph reports (#5815)
* draw zero points

* note

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-05 14:22:51 +01:00
Matt Fiddaman
71f849d1e1 ♻️ bump eslint-plugin-actual dependencies (#5818)
* eslint 9.27.0 -> 9.36.0

* eslint-plugin-eslint-plugin 6.4.0 -> 7.0.0

* eslint-vitest-rule-tester 2.2.0 -> 2.2.2

* note

* misc eslint deps
2025-10-05 14:22:42 +01:00
Matt Fiddaman
0ea8bc1fb4 expand eslint untranslated string rule (#5827)
* expand translation rule and abstract import fix implementation

* fixes

* note

* coderabbit
2025-10-05 14:22:14 +01:00
Matt Fiddaman
f0c7953c0b ♻️ refactor rules code (#5837)
* extract handlebars helpers

* extract condition types

* extract condition class

* extract action class

* extract rule class

* extract rule indexer

* extract rule utils

* update main index

* note

* enable strict where able

* generalise assert

* coderabbit

* move condition-types into condition, move helper functions into rule-utils
2025-10-05 14:22:02 +01:00
Matt Fiddaman
4cf5f9b183 add average per year calculation to the summary report (#5838)
* add average per year to summary report

* note

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-05 14:21:52 +01:00
Michael Clark
80fd997540 Reports - Add an option to trim the start & end intervals (#5641)
* initial test to trim the intervals

* bit more

* got the logic

* fix table data

* add migration for trim intervals

* release notes

* nice work rabbit

* small cleanup

* not sure how major that is but yeah why not
2025-10-05 10:48:35 +01:00
Michael Clark
da93ddf63b 🛠️ Add retries to electron loot-core import (#5843)
* add retries to electron server import

* release notes
2025-10-05 10:48:05 +01:00
github-actions[bot]
7846d2e787 🔖 (25.10.0) (#5834)
* 🔖 (25.10.0)

* Remove used release notes

* Remove used release notes

---------

Co-authored-by: matt-fidd <81489167+matt-fidd@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-02 11:55:47 +01:00
youngcw
ca6d80461a 🐛 fix limit checker (#5835) 2025-10-01 09:04:34 -07:00
Matt Fiddaman
fa14cbb697 fix decimal input in amount input boxes (#5831)
* preserve decimal seperators while typing in input boxes

* note
2025-10-01 16:27:51 +01:00
Matt Fiddaman
1210a74b4a fix error handling for simplefin batch sync (#5822)
* fix error handling for batch simplefin sync

* note
2025-09-30 23:51:17 +01:00
Matt Fiddaman
534c1e6680 fix crash when switching reports (#5823)
* fix report autocomplete

* note
2025-09-30 21:24:46 +01:00
Matt Fiddaman
14d436712a move balance history graph to live queries (#5821)
* move balance history graph to live queries

* note

* [autofix.ci] apply automated fixes

* coderabbit suggestion

* fix null starting balance

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-30 20:27:22 +01:00
Matt Fiddaman
e9f3925124 prevent the account balance graph from showing on small screen sizes (#5816)
* prevent account balance graph from showing on small screen sizes

* note

* lint
2025-09-30 00:46:26 +01:00
Matt Fiddaman
f28229be99 fix payee autocomplete hovering randomly (#5817)
* use correct index for payee autocomplete hover states

* note

* coderabbit
2025-09-30 00:46:07 +01:00
Matt Fiddaman
1fc922c672 skip running the schedule service if the database is not loaded (#5810) 2025-09-29 14:29:17 +01:00
Matiss Janis Aboltins
c712217a7c Update PayeesList component to use flex styling for improved layout consistency (#5803) 2025-09-28 06:31:30 +01:00
Matiss Janis Aboltins
3559b2df3a Mobile Payees - move to react-aria GridList to improve performance (#5802) 2025-09-27 21:54:16 +01:00
Matt Fiddaman
6365a8f4bb replace deprecated function in count points script (#5791)
* fix deprecated function call in count points script

* note
2025-09-25 21:03:44 +01:00
Matt Fiddaman
14426b64fd fix live report time ranges (#5790)
* fix live range

* note

* use latest of currentMonth/latestTransaction

* [autofix.ci] apply automated fixes

* standardise card code

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-25 19:16:40 +01:00
Matiss Janis Aboltins
65790d4b9c Fix token expiration parsing (#5782) 2025-09-25 18:07:26 +01:00
Michael Clark
9af4ba4d07 🔽 Downgrade Ubuntu image for more compatibility with older distros (#5788)
* downgrade ubuntu image for more compatibility with older distros

* release ntoes
2025-09-24 19:37:31 +01:00
Matiss Janis Aboltins
28caf8eaf9 Add data-1p-ignore to transaction amounts (#5783) 2025-09-24 17:15:16 +01:00
lelemm
81160256bc Frontend plugins Support [1/10]: CORS proxy (#5780)
* Frontend plugins Support [1/10]: Cors proxy

* Add release notes for PR #5780

* changed code as CodeQL suggested

* CodeQL improvement for ip validation to bypass dns changes

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Code Rabbit suggestion

* Update packages/sync-server/src/app-cors-proxy.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Added env var for cors proxy

* multiple changes

* missed updating yarn.lock

* making code rabbit happy

* Tests

* linter

* Code Rabbit changes

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-24 08:13:24 -03:00
Joel Jeremy Marquez
ca5378c0e8 Optimize scroll provider and replace usage of debounce package with lodash's debounce (#5750)
* Optimize scroll provider and replace usage of debounce package with lodash's debounce

* Coderabbit suggestions
2025-09-23 15:59:55 -07:00
youngcw
08b5b7fdc7 add limit template type (#5775)
* add limit template

* use null to match other tempaltes

* add in directive

* handle no limit preset

* limit periods aren't optional

* make start field the right type

* fix test

* fix looping

* remove unneeded comment
2025-09-23 14:34:36 -07:00
Matiss Janis Aboltins
67c0b6911b Mobile payees: click handlers (#5776) 2025-09-23 22:18:37 +01:00
Matt Fiddaman
4e9e153989 ensure file upload size limits are respected when syncing files (#5779) 2025-09-23 18:06:45 +01:00
Matiss Janis Aboltins
b0321ee265 refactor: update table and import transactions modal components (#5770) 2025-09-23 16:49:03 +01:00
milanalexandre
753a105b3d [WIP] [FIX] Translate Schedule '(No payee)' (#5777) 2025-09-22 22:56:41 +01:00
Matiss Janis Aboltins
5a888d44b9 Create mobile payees list page (#5767) 2025-09-22 18:56:57 +01:00
thromer
7a4799de94 Avoid repeated calls to payee.find in useDisplayPayee.ts (#5761) 2025-09-22 16:26:14 +01:00
Amr Awad
4ad369cd8f add a 'dryRun' option to transactions import API (#5758) 2025-09-22 16:24:35 +01:00
Matt Fiddaman
2c9a66cec6 API quiet mode (#5762)
* initial implementation of api quiet mode

* note

* lint rule

* rollout logger to loot-core

* add group methods

* windows paths

* make fixer more robust

* appease linter

* add debug

* change option name quiet -> verbose
2025-09-22 12:54:08 +01:00
Matiss Janis Aboltins
6e96b81799 fix: add minimum width to value editor in ConditionEditor component (#5766) 2025-09-22 12:04:43 +01:00
milanalexandre
f89d4fd13d [FIX] Translate 'Off budget' and 'Transfer' (#5765)
* translate transaction edit modal (mobile)

* add relese note

* fix warn

---------

Co-authored-by: Alex <Alex>
2025-09-22 09:42:03 +01:00
Matt Fiddaman
cc0812113a improve cleanup when opening multiple budgets through the API (#5760) 2025-09-21 17:47:27 +01:00
Matt Fiddaman
59724d445f fix nuisance timestamp error in API (#5759) 2025-09-21 17:47:10 +01:00
Çağdaş Şenel
6b99497d5d feat(reports): extend report end date to the latest transaction date (#5753)
* add get-latest-transaction function to extend end dates beyond today

* add release notes

* yarn lint

* fix dateEnd and lint warnings

* change getFullRange to return all months

* Update upcoming-release-notes/5753.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-09-21 13:28:08 +01:00
Matt Fiddaman
5f5457b226 fix scrolling inside modal with an input that autofocuses (#5752) 2025-09-20 20:03:01 +01:00
dependabot[bot]
4bdcb27573 Bump @eslint/plugin-kit from 0.3.1 to 0.3.5 (#5749)
Bumps [@eslint/plugin-kit](https://github.com/eslint/rewrite/tree/HEAD/packages/plugin-kit) from 0.3.1 to 0.3.5.
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/plugin-kit/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/plugin-kit-v0.3.5/packages/plugin-kit)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-version: 0.3.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 11:47:11 +01:00
Joel Jeremy Marquez
8ae070ab12 React Compiler (#5562)
* Highlight first suggestion when searching instead of the split option

* Fix error

* Try out react compiler

* Fix typecheck errors

* Increase --max-old-space-size

* Increate --max-old-space-size in package-browser

* Update config and versions

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* Update versions

* Fix typecheck errors

* Revert some changes

* Implement react compiler to take advantage of some performance improvements

* Fixes

* Remove unused packages

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-19 14:28:16 -07:00
dependabot[bot]
0ca5bec094 Bump axios from 1.8.3 to 1.12.1 (#5719)
Bumps [axios](https://github.com/axios/axios) from 1.8.3 to 1.12.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.3...v1.12.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-19 17:32:06 +01:00
dirk-apers
988bc21818 enhancement: add BPER Italy bank parser (BPER_RETAIL_BPMOIT22) (#5741)
* Refine BPER retail parser with anonymized fixtures

* style: streamline bper parser comments

* chore: add release note for BPER Italy parser
2025-09-19 17:31:04 +01:00
Stephen Brown II
f4419b96de Dynamic currency display options (#5744) 2025-09-19 17:06:21 +01:00
passabilities.eth
e30a38ced8 [Feature] Account Net Worth Graph (#5037)
* show net worth graph for each account page

* add release notes

* hide filter button

* fix lint

* import ReactNode type

* find selected accounts based on accountId param

* remove breaks

* can toggle account page net worth graph

* Improves account balance history graph
Refactors the balance history graph to improve its data fetching and rendering.

Removes the `selectedAccounts` state and related logic from the Account component, simplifying its state management. The `accountId` is now passed directly to the BalanceHistoryGraph.

Moves the BalanceHistoryGraph component to the accounts directory and removes the redundant BalanceHistoryGraph in the sidebar.

Updates the queries to directly use the accountId for filtering transactions, leading to more efficient data retrieval.

* mv

* min width and height only for sidebar

* move toggle chart button to account menu

* add fill gradiant to chart

* fix maxWidth

* fix chart hover offset

* responsive graph aspect ratio

* auto hide chart on short view

* revert balance position

* auto hide chart on 15% height

* remove chart boolean from internal state

* Implement account-specific net worth chart preferences

Replaced global net worth chart visibility preference with account-specific preferences. Updated components to use the new dynamic preference keys and added a toggle option in the account menus for better user customization.

* lint

* fix account groups

* Update VRT

* bump

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-09-18 13:32:21 -07:00
Michael Süssemilch
98b91cfb8d fix: make sure cumulative value can not be undefined (#5738)
* fix: make sure cumulative value can not be undefined, which results in NaN

* doc: add release notes
2025-09-18 08:20:57 -07:00
Josh Woodward
942d3ea4d5 Schedules with the same amount and date are now handled properly (#5414)
* minor typescript updates

* [autofix.ci] apply automated fixes

* more typescript changes

* [autofix.ci] apply automated fixes

* Another typescript

* fixes

* [autofix.ci] apply automated fixes

* fixed test

* [autofix.ci] apply automated fixes

* renaming a few things

* [autofix.ci] apply automated fixes

* a test

* test 2

* [autofix.ci] apply automated fixes

* test 3

* attempting to add a new test

* [autofix.ci] apply automated fixes

* test update

* additional typing.

* [autofix.ci] apply automated fixes

* Testing first item again

* [autofix.ci] apply automated fixes

* temporarily adding logging

* [autofix.ci] apply automated fixes

* only running rules within a schedule if exists

* swapping if

* removing unused import

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fixing typecheck

* updated test

* [autofix.ci] apply automated fixes

* Updated screenshots

* further test fixing

* changing test order

* [autofix.ci] apply automated fixes

* Almost there

* new images

* tests may be flaky

* just one image

* 🙏

* 🙏 🙏

* removing all images

* Revert "removing all images"

This reverts commit 4492cb7080.

* small order change

* [autofix.ci] apply automated fixes

* Update VRT

* Update packages/desktop-client/e2e/schedules.test.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* reverting unrelated changes

* [autofix.ci] apply automated fixes

* Reverting one other typescript change

* testing completing 1 schedule

* Update VRT

* release wrote rewrite

* Making sure rule application is saved

* Cleaning up get function

* prettier and null catch

* Removed now unused schedule type

* Better falling back if rule / schedule no longer exists

* linting

* Better typing for db request

* camel case to make code rabbit happier

* slightly more accurate comment

* [autofix.ci] apply automated fixes

* Running other rules when linked rule runs

* lint / prettier

* one more lint

* Update 5414.md

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-16 20:12:31 -07:00
Matiss Janis Aboltins
3c9b70df79 Integrate responsive design for RecurringSchedulePicker component (#5733) 2025-09-16 21:33:30 +01:00
milanalexandre
5c18b53888 [fix] make the StatusLabel text appear on a single line (#5736)
* make the StatusLabel text appear on a single line

* add relese note

---------

Co-authored-by: Alex <Alex>
2025-09-16 13:30:39 -07:00
mgibson-scottlogic
413398531c Fix copy last month's budget including hidden categories (#5735)
* Add check that category is not hidden before copying last months budget

* Add release notes

* Add check that group is also not hidden

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-09-16 11:21:50 -07:00
jgeneaguilar
e4c3d4e12a Fix AccountMenu popover in Italian (#5734) 2025-09-16 16:54:35 +01:00
Michael Clark
91b838c539 🔔 Add setting to disable update notifications (#5725)
* add setting to disable update notifications

* release notes

* fix dependency array

* Update packages/loot-core/src/server/preferences/app.ts

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* moving latest version info into app state to prevent needless calls to external api

* fix release note

* Update VRT

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-09-15 09:12:24 +01:00
Joel Jeremy Marquez
9eb0e04c6a [Maintenance] Remove raw variables in render (#5718)
* Remove usage of raw variables in renders

* Release notes

* Update status

* Fix typecheck error
2025-09-12 15:26:09 -07:00
Joel Jeremy Marquez
14bf3d611c [Maintenance] Remove usage of a raw variable in CategoryAutocomplete component (#5686)
* Remove usage of a raw variable in CategoryAutocomplete component

* Cleanup

* Fix typecheck error

* Fix typecheck error

* Highlight first suggestion when searching instead of the split option

* Fix error

* Fix special item highligted index
2025-09-12 15:25:52 -07:00
Joel Jeremy Marquez
34b6599da3 Re-design mobile accounts page to better match the transactions and budget tables/list + add all accounts page (#5610)
* Re-design mobile accounts page to better match the transactions and budget tables/list + add all accounts page

* [autofix.ci] apply automated fixes

* Update to "All accounts" to match desktop text

* Update prop

* Update VRT

* Increase height

* Update VRT

* Update UI

* Update VRT

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
2025-09-11 09:17:07 -07:00
Evan Smith
bc1cd9023c Remove auto-scrolling behavior when editing split transactions on mobile (#5572)
* Remove behavior to auto scroll to transaction with 0 amount

* Add release notes
2025-09-11 16:26:21 +01:00
dependabot[bot]
5ae9176f5e Bump vite from 6.3.5 to 6.3.6 (#5707)
* Bump vite from 6.3.5 to 6.3.6

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-09-11 16:19:48 +01:00
Joel Jeremy Marquez
2ed908aff4 Remove usage of a raw variable in Account component (#5684) 2025-09-11 08:13:24 -07:00
Matt Fiddaman
3318dd56e9 remove BANKS_WITH_LIMITED_HISTORY override array for GoCardless (#5714)
* remove banks with limited history gocardless array

* note

* [autofix.ci] apply automated fixes

* set 89 in fallback

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-11 15:48:43 +01:00
Michael Clark
00ab11cc40 :electron: Update Electron to v38 (#5713)
* update electron to 38

* release notes
2025-09-11 15:40:40 +01:00
youngcw
25c83eb64d Add bank sync option to only import balance (#5711)
* add investment account setting

* note

* disable other options if enabled

* handle 0 balance

* Update packages/loot-core/src/server/accounts/sync.ts

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* [autofix.ci] apply automated fixes

* dont exit early

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-11 07:15:57 -07:00
Mauro Artizzu
7a420b79f2 Handle detailedAccount null or undefined in gocardless service. Fixes #5664 (#5706)
* fix detailedAccount could be null or undefined

* add release notes
2025-09-11 14:45:43 +01:00
Nalin Gupta
d2cfedf5e4 Fixes #5674 mark transfer issue (#5696)
* Fixes #5674 mark transfer issue

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-09 20:32:55 -07:00
Shalan
00a4cfcabf feat: add EGP and SAR currencies to supported list (#5698)
* feat: add EGP and SAR currencies to supported list

* add release notes for currency support

* use yarn generate:release-notes to fix release notes

* Rename file with PR number 5698
2025-09-08 08:04:55 -07:00
Karim Kodera
a18a05f55a Introduction of APIs to handle Schedule + a bit more. (#5584)
* Introduction of APIs to handle Schedule + a bit more. Refer to updated API documentation PR2811 on documentation.

* Fixed lint Error

* Removed unused declarations

* Fixed Bug in Test module

* Avoiding type Coercion fixes and direct assignment on conditions array.

* Lint Error Fixes

* more issues fixed

* lint errors

* Remove with Mutation in Get function. Fixed quotes. updated getIDyName for error handling

* More lint errors

* Minor final fixes.

* One type coercion removed

* One type coercion removed

* Revert back to original working code

* Added Account testing for both get by ID and updating schedules

* [autofix.ci] apply automated fixes

* Added Payee tests as well

* Payee Tests

* [autofix.ci] apply automated fixes

* Optimized condition checking at the beginning of the code and avoid ambiguity in case of corrupt schedule

* Mode debug infor on error in testing

* better bug tracking

* //more trouble shooting

* Bug fixed

* [autofix.ci] apply automated fixes

* Minor mofication to satisfy code rabbit. We should be ready for review.

* [autofix.ci] apply automated fixes

* Removing type coercion from the model

* [autofix.ci] apply automated fixes

* fixed compilation error

* Fixed new bugs

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-08 09:03:11 +01:00
Matiss Janis Aboltins
b399f290a6 Add ErrorBoundary around Modals and updating FatalError modal behavior. (#5649)
Fix #4703
2025-09-07 19:14:31 +01:00
Matiss Janis Aboltins
7c07295448 Issues: add issue types (#5648) 2025-09-07 19:13:15 +01:00
Michael Clark
510dd31de6 🔧 Fix heap allocation error when building locally with docker (#5695)
* fix heap allocation error when building locally with docker

* release notes

* clarifying comment
2025-09-07 13:32:40 +01:00
Michael Sanford
8e5a88bc55 Add NO_COLOR standard environment flag to sync-server logging. (#5676)
* Add NO_COLOR standard environment flag to sync-server logging.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-05 15:31:12 -07:00
Joel Jeremy Marquez
bbf91ccbca [Maintenance] Remove usage of a raw variable in PayeeAutocomplete component (#5687)
* Remove usage of a raw variable in PayeeAutocomplete component

* Add note on 100 payees limit
2025-09-05 12:26:44 -07:00
Joel Jeremy Marquez
58bc14e1b3 Optimize usage of useScrollListener and useTransactionsSearch (#5690) 2025-09-05 12:26:05 -07:00
Joel Jeremy Marquez
de2966a06c [Maintenance] Remove usage of a raw variable in AccountAutocomplete component (#5685)
* Remove usage of a raw variable in AccountAutocomplete component

* Remove usage of a raw variable in CategoryAutocomplete component

* Release notes

* Fix typecheck error

* Fix typecheck errors

* Remove CategoryAutocomplete changes
2025-09-05 11:21:59 -07:00
Michael Süssemilch
90b859fd74 feat(currency): add BRL, JMD, RSD, RUB, THB and UAH (#5653)
* feat: add BRL, JMD and THB currencies

* feat: add RSD, RUB and UAH
2025-09-05 10:57:29 -07:00
biolan
fafcee071d Add Romanian and Moldovan currency (#5688)
* * add Romanian Leu currency

* add Moldovan Leu currency

* Add release note

* * Add release note

* * one line release note

---------

Co-authored-by: biolan <admin@biolan.dev>
2025-09-05 07:05:56 -07:00
Bernardo Jordão
ed40901534 Fix range calculator on the MonthPicker component (#5622)
* fix month range

* release notes

* use floor() to match e2e tests
2025-09-05 06:41:23 -07:00
Julian Dominguez-Schatz
338093836b Add tools to migrate/un-migrate to/from UI automations (#5624)
* Add helper to render automations to template notes

* Add modal to un-migrate from the automations UI

* Add import warning to automations modal

* Add release notes

* Use CSSProperties type from React directly
2025-09-05 07:32:17 -04:00
Julian Dominguez-Schatz
4df05aa37c Fix version bump logic to work if the month has rolled over (#5662)
* Fix version bump logic to work if the month has rolled over

* Refactor script to be more testable

* Add tests for regression

* Move tests to dedicated package

* Add release notes

* Coderabbit
2025-09-05 07:31:53 -04:00
465 changed files with 92540 additions and 6248 deletions

View File

@@ -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`.

View File

@@ -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:

View File

@@ -2,6 +2,7 @@ name: Feature request
description: Request a missing feature
title: '[Feature] '
labels: ['feature']
type: Feature
body:
- type: markdown
attributes:

View File

@@ -1,117 +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, 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'];
// 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(nextVersionMonthDate.getFullYear() < 2100 ? -2 : -3);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
// Get current date string
const currentDate = new Date();
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replaceAll('-', '');
if (values.type === 'auto') {
if (currentDate.getDate() <= 25) {
values.type = 'hotfix';
} else {
values.type = 'monthly';
}
}
let newVersion;
switch (values.type) {
case 'nightly': {
newVersion = `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDateString}`;
break;
}
case 'hotfix': {
newVersion = `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
break;
}
case 'monthly': {
newVersion = `${nextVersionYear}.${nextVersionMonth}.0`;
break;
}
default:
console.error(
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
);
process.exit(1);
}
process.stdout.write(newVersion); // return the new version to stdout
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);
}

View File

@@ -2,7 +2,7 @@ import { Octokit } from '@octokit/rest';
import { minimatch } from 'minimatch';
import pLimit from 'p-limit';
const limit = pLimit(30);
const limit = pLimit(50);
/** Repository-specific configuration for points calculation */
const REPOSITORY_CONFIG = new Map([
@@ -129,13 +129,13 @@ async function countContributorPoints(repo) {
// Get all PRs using search
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
const recentPRs = await octokit.paginate(
octokit.search.issuesAndPullRequests,
'GET /search/issues',
{
q: searchQuery,
per_page: 100,
advanced_search: true,
},
response => response.data,
response => response.data.filter(pr => pr.number),
);
// Get reviews and PR details for each PR

View File

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

View File

@@ -24,7 +24,7 @@ jobs:
strategy:
matrix:
os:
- ubuntu-latest
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}

View File

@@ -19,7 +19,7 @@ jobs:
strategy:
matrix:
os:
- ubuntu-latest
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}

View File

@@ -37,7 +37,7 @@ jobs:
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version="${{ github.event.inputs.version }}"
else
version=$(node ./.github/actions/get-next-package-version.js \
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)

View File

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

View File

@@ -19,7 +19,7 @@ jobs:
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.55.1-jammy
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version

2
.gitignore vendored
View File

@@ -26,6 +26,8 @@ packages/desktop-electron/build
packages/desktop-electron/.electron-symbols
packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
bundle.desktop.js
bundle.desktop.js.map
bundle.mobile.js

605
PLUGIN_ARCHITECTURE.md Normal file
View File

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

View File

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

View File

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

View File

@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
echo "E2E_START_URL: $E2E_START_URL"
echo "VRT_ARGS: $VRT_ARGS"
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.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"

View File

@@ -74,27 +74,27 @@ const confusingBrowserGlobals = [
export default pluginTypescript.config(
{
ignores: [
// Global ignore patterns
'**/node_modules/**',
'**/dist/**',
'**/*.zip',
// Specific ignore patterns
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/dist',
'packages/api/@types',
'packages/api/migrations',
'packages/crdt/dist',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/build/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
'packages/desktop-client/public/data/',
'packages/desktop-client/**/node_modules/*',
'packages/desktop-client/node_modules/',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
'packages/desktop-electron/build/',
'packages/desktop-electron/dist/',
'packages/loot-core/**/node_modules/*',
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/build/',
@@ -154,9 +154,6 @@ export default pluginTypescript.config(
{
plugins: {
actual: pluginActual,
'react-hooks': pluginReactHooks,
'jsx-a11y': pluginJSXA11y,
'typescript-paths': pluginTypescriptPaths,
},
rules: {
'actual/no-untranslated-strings': 'error',
@@ -165,6 +162,10 @@ export default pluginTypescript.config(
},
{
files: ['**/*.{js,ts,jsx,tsx}'],
plugins: {
'jsx-a11y': pluginJSXA11y,
'react-hooks': pluginReactHooks,
},
rules: {
// http://eslint.org/docs/rules/
'array-callback-return': 'warn',
@@ -450,6 +451,7 @@ export default pluginTypescript.config(
'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',
@@ -630,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',
@@ -758,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',
@@ -771,6 +788,7 @@ export default pluginTypescript.config(
rules: {
'actual/typography': 'off',
'actual/no-untranslated-strings': 'off',
'actual/prefer-logger-over-console': 'off',
},
},
{

View File

@@ -23,17 +23,20 @@
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
@@ -44,7 +47,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "prettier --check . && eslint . --max-warnings 0",
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
@@ -55,32 +58,32 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.17.0",
"@types/node": "^22.18.8",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.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-react-hooks": "^6.1.1",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"lint-staged": "^16.2.3",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"prettier": "^3.5.3",
"prettier": "^3.6.2",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.32.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
@@ -99,7 +102,7 @@
},
"packageManager": "yarn@4.9.1",
"browserslist": [
"electron 24.0",
"electron >= 35.0",
"defaults"
]
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
{
"name": "@actual-app/ci-actions",
"private": true,
"type": "module",
"devDependencies": {
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest"
}
}

View 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”.',
);
}
}

View File

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

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

View File

@@ -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": {

View File

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

View File

@@ -17,13 +17,13 @@
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^11.1.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-4",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

View File

@@ -14,6 +14,9 @@ build-electron
build-stats
stats.json
# generated service worker
service-worker/
# misc
.DS_Store
.env

View File

@@ -65,10 +65,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

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