Compare commits

...

271 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
Julian Dominguez-Schatz
5459b8baca 🔖 (25.9.0) (#5663)
* 🔖 (25.9.0)

* Remove used release notes

* Empty commit to bump CI

* Add release notes for PR #5663

* Remove AI-generated release note

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-09-03 19:27:22 -04:00
Julian Dominguez-Schatz
073d91a7b7 Fix income modal balance not showing (#5659)
* Fix income modal balance not showing

* Add release notes
2025-09-02 10:02:44 -04:00
Gediminas Lelešius
58a638cee2 [fix] Support webRoot being in a hidden folder (e.g. .cache) (#5643)
Fixes #5642 and makes npx and pnpx work for @actual/sync-server
2025-08-29 15:52:29 -04:00
milanalexandre
23f1bae7db [fix] Adjust modal styles and text wrapping in TransactionError (#5634)
* adaptive size of the modal

* add relese note

---------

Co-authored-by: Alex <Alex>
2025-08-27 13:24:31 -04:00
Matt Fiddaman
57240284a3 fix health check script (#5631)
* fix health-check

* note
2025-08-26 16:10:45 -04:00
Matiss Janis Aboltins
6c6d8931bb Release Pluggy bank-sync integration (#5628) 2025-08-26 18:17:20 +01:00
Michael Clark
cae8fa4e6f :electron: Building bcrypt for correct architecture during packaging (#5623)
* rebuilding bcrypt

* release notes
2025-08-25 09:41:45 +01:00
Michael Süssemilch
48ae371ecc fix: crash on filtering by amount (#5608)
* fix: crash on filtering by amount

* doc: add release notes
2025-08-23 10:55:22 +01:00
Matiss Janis Aboltins
e8d93fb797 Implement responsive DateSelect component for mobile view (#5607) 2025-08-22 22:13:16 +01:00
Joel Jeremy Marquez
6790f99de2 [Mobile] Show uncategorized/overspending totals on budget banners (#5605)
* [Mobile] Show uncategorized/overspending totals on budget banners

* Cleanup

* Update VRT

* Coderabbit feedback

* Remove console.log

* Rename release notes

* Use Array.from

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-08-21 21:26:55 -07:00
Joel Jeremy Marquez
68f0b05aed [Redux] Rename queriesSlice to transactionsSlice (#5598)
* Move getAccountsById to accountsSlice

* [autofix.ci] apply automated fixes

* [Redux] Rename queriesSlice to transactionsSlice

* Fix import

* Release notes

* Remove nonexistent saved state

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-21 18:16:43 -07:00
Joel Jeremy Marquez
c954d3924e [Redux] Move tags states from queriesSlice to tagsSlice (#5597)
* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* [autofix.ci] apply automated fixes

* Move getAccountsById to accountsSlice

* [autofix.ci] apply automated fixes

* [Redux] Move tags states from queriesSlice to tagsSlice

* Fix lint + release notes

---------

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-08-21 15:31:06 -07:00
Joel Jeremy Marquez
adf4bd2d0f Enable Make Transfer menu for child transactions (#5604) 2025-08-21 15:24:16 -07:00
Matiss Janis Aboltins
102c6eaff6 Enhance responsiveness of GenericInput in RuleEditor component for mobile devices (#5601)
* Enhance responsiveness of GenericInput in RuleEditor component for mobile devices

* Refactor ActionEditor layout in RuleEditor component for improved responsiveness

- Adjusted styles for the View components to enhance layout flexibility.
- Ensured the GenericInput component maintains appropriate minimum width based on method type.
- Updated Stack component to prevent shrinking, improving overall UI consistency.
2025-08-21 22:17:17 +01:00
Joel Jeremy Marquez
21105fc25b [Redux] Move category states from queriesSlice to budgetSlice (#5593)
* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* [autofix.ci] apply automated fixes

* Move getAccountsById to accountsSlice

* [autofix.ci] apply automated fixes

* Fix merge error

* [autofix.ci] apply automated fixes

* [Redux] Move category states from queriesSlice to budgetSlice

* Move getCategoriesById to budgetSlice + fix lint

* Fix lint

* [autofix.ci] apply automated fixes

* Fix typecheck

* Add new slices to boundActions

---------

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-08-21 14:15:30 -07:00
Matiss Janis Aboltins
c69142f58e Refactor schedule handling in ManageRules component (#5600)
- Updated the ManageRules component to use ScheduleEntity type for schedules.
- Improved the describeSchedule function to include type annotations for better clarity.
- Added a conditional check to handle cases where the schedule may not exist, ensuring robust functionality.
2025-08-21 20:09:21 +01:00
Matiss Janis Aboltins
fe32bf14c6 Mobile rules - update default stage to be "default" instead of "pre" (#5587) 2025-08-21 19:24:52 +01:00
Joel Jeremy Marquez
92e43bc3b5 [Redux] Move payee states from queriesSlice to payeesSlice (#5592)
* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* [autofix.ci] apply automated fixes

* Move getAccountsById to accountsSlice

* [autofix.ci] apply automated fixes

* [Redux] Move payee states from queriesSlice to payeesSlice

* Fix merge error

* Fix merge error

* [autofix.ci] apply automated fixes

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

* Fix import

---------

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-08-21 08:51:38 -07:00
Joel Jeremy Marquez
165be3d0df [Redux] Move account states from queriesSlice to accountsSlice (#5581)
* Add rejected reducers

* Add dirty states

* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* [autofix.ci] apply automated fixes

* [Redux] Move account states from queriesSlice to accountsSlice

* Release notes

* Move getAccountsById to accountsSlice

* [autofix.ci] apply automated fixes

---------

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-08-21 08:16:22 -07:00
youngcw
3dd22994b7 fix bug (#5594) 2025-08-21 07:56:09 -07:00
Michael Clark
96bfc69332 Command bar VRTs (#5591)
* command bar vrts

* release notes

* reduce flakiness

* oh

* add screenshots back in

* Update VRT

* trigger

* reducing screenshots

* Update VRT

* trigger

* jeez

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-08-21 09:08:46 +01:00
Joel Jeremy Marquez
284fc13161 [Mobile] Do not show transfer transactions on uncategorized transactions view/page (#5589)
* [Mobile] Do not show transfer transactions on uncategorized transactions view/page

* [autofix.ci] apply automated fixes

* Rename

* Add release notes for PR #5589

* Change release notes to bugfix

---------

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>
2025-08-20 14:09:53 -07:00
Matiss Janis Aboltins
30102b1474 Revert "Dispatch github ci jobs after running update-vrt workflow (#5586)" (#5590)
This reverts commit 3a8eb96d76.
2025-08-20 21:28:27 +01:00
Matiss Janis Aboltins
3a8eb96d76 Dispatch github ci jobs after running update-vrt workflow (#5586)
* Enhance CI workflows to support repository dispatch events and improve branch handling

- Added support for `repository_dispatch` events in build, check, and codeql workflows.
- Updated concurrency group to include `client_payload.pr_number` for better context.
- Modified checkout steps to reference the correct branch from `client_payload` if available.

* Add release notes for PR #5586

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-08-20 21:12:16 +01:00
Joel Jeremy Marquez
91a8bc3ef1 Prevent unnecessary calls to DB if same redux action is already pending (#5579)
* Prevent unnecessary calls to DB if same redux action is already pending

* [autofix.ci] apply automated fixes

* Release notes

* Add rejected reducers

* Add dirty states

* Remove commented code

* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* Update packages/desktop-client/src/queries/queriesSlice.ts

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

* [autofix.ci] apply automated fixes

* Coderabbit feedback

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-19 19:42:15 -07:00
Julian Dominguez-Schatz
dc2ab4843f Connect automations UI to backend (#5533)
* Connect automations UI to backend

* Fix integer -> amount

* Add release notes

* Fix length check

* No layout shift

* Coderabbit: decimal places
2025-08-19 20:30:09 -04:00
Matt Fiddaman
89e5676cfb only show schedule name in notes for upcoming transactions (#5580) 2025-08-19 19:06:27 -04:00
POGMAN
645342d47d Compute color of tags text for dark mode (#5574) 2025-08-19 10:16:45 -07:00
Julian
116c695964 fix filter transactions by closed account transfer payee (#5438)
* fix filter transactions by closed account transfer payee

* apply equivalent ignore pattern to arguments as variables in eslint

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-19 08:14:40 -07:00
Joel Jeremy Marquez
a5d18929c8 Fix AutoTextSize warnings (#5576)
* Fix AutoTextSize warnings

* Release notes
2025-08-19 08:14:21 -07:00
Matt Fiddaman
989d332e1b fix rerender loop in calendar card (#5573) 2025-08-18 11:19:45 -04:00
Michael Clark
169d08e721 🗑️ Remove old sourcemap and process package (#5567)
* remove old sourcemap package and process package

* release notes

* Update 5567.md
2025-08-18 08:54:44 +01:00
milanalexandre
a74da11904 translate import transactions (#5565)
* translate import transactions

* translate import transactions headers

* add relese note

* feed back

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>

---------

Co-authored-by: Alex <Alex>
Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
2025-08-17 07:39:55 -07:00
Michael Clark
cccd66713d 🗑️ Remove unused package (reselect) (#5566)
* remove unused package

* release notes
2025-08-16 21:04:41 +01:00
Matiss Janis Aboltins
1ce53b2762 feat(mobile): ability to delete rules (#5556) 2025-08-16 17:56:22 +01:00
milanalexandre
d75f984186 Translate closing account and more (#5549)
* translate

* add relese note

* Update VRT

* feed back

* Update VRT

* revert vrt

* avoid duplicate translations

* translate rename save butons (reports custom)

---------

Co-authored-by: Alex <Alex>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-08-15 18:06:38 -07:00
Matiss Janis Aboltins
692ade7254 Extract mobile rules form to a new page (#5516) 2025-08-14 21:52:47 +01:00
dependabot[bot]
da0ac0b144 Bump tmp from 0.2.1 to 0.2.5 (#5551)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.1 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.1...v0.2.5)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.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-08-13 22:07:53 +01:00
Michael Clark
be20f65b6e 🛠️ Moving loot-core to Vite (#5379)
* Moving loot core to Vite
2025-08-13 20:49:48 +01:00
Joel Jeremy Marquez
1067e32028 [Mobile] Fix preview transactions not loading in multi-account views and uncategorized splits not loading (#5072)
* Fix preview transactions not loading in multi-account views and uncategorized splits not loading

* Update release notes

* Fix typecheck error

* Coderabbit feedback

* Fix lint error

* Fix banners

* Fix uncategorized view to use new route

* Updates

* Update VRT

* Dummy commit

* Fix lint

* [autofix.ci] apply automated fixes

* Update loading

* Coderabbit feedback

* [autofix.ci] apply automated fixes

* Delete old snapshots

* Translate Closed

* Update Closed translation

* Update translation

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

* [autofix.ci] apply automated fixes

---------

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: Matt Fiddaman <github@m.fiddaman.uk>
2025-08-13 11:05:05 -07:00
Johannes Maas
dcb1c69e67 Display absolute date of reconciliation (#5521) 2025-08-13 13:47:27 -04:00
Matiss Janis Aboltins
f084e28086 Added CODEOWNERS file (#5544) 2025-08-12 22:10:10 +01:00
Joel Jeremy Marquez
f54e459e03 Fix transaction hooks and improve transactions loading experience in mobile (#5415)
* Fix transaction hooks and improve transactions loading experience in mobile

* Allow skipping of running balance calculation on preview transactions + recalculate running balances if there are any inversed transaction amounts

* [autofix.ci] apply automated fixes

* Disable PullToRefresh when transaction list is in loading state (See #5080)

* Cleanup

* Add calculateRunningBalancesTopDown to calculate top down from starting balance

* update balance sheet value

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-12 13:57:17 -07:00
Matiss Janis Aboltins
ccdde60bfe TypeScript: convert edit rules modal (#5543) 2025-08-12 21:41:13 +01:00
Michael Süssemilch
712d315229 fix: hidden category selection and include hidden when they are shown on budget (#5526)
* fix: hidden category selection

* chore: add (hidden) to hidden categories and groups

* feat: add hidden category to filter

* feat: show hidden categories in mobile

* refactor: remove unused variable
2025-08-12 12:57:54 -07:00
milanalexandre
31c6362307 Fix SpendingCard crash (#5541) 2025-08-12 09:51:10 -04:00
CyberSardinha
d1519993d6 Add gocardless support for Stadtsparkasse München (#5383)
* Add files via upload

* added credits to the original author Nebukadneza

* [autofix.ci] apply automated fixes

* Create 5383.md

* Improve Stadtsparkasse München GoCardless transaction parsing

* Improve Stadtsparkasse München GoCardless transaction parsing

* Update 5383.md

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-12 01:18:55 -04:00
Julian Dominguez-Schatz
ebde78434a Add backend logic to support automations UI (#5532)
* Add backend logic to support automations UI

* Fix tests

* Add release notes

* PR feedback: default priority

* PR feedback: template_settings

* PR feedback: batch

* PR feedback: no remainder special-casing

* Simplify SQL

* Fix tests

* Coderabbit

* Schema default
2025-08-11 23:39:09 -04:00
Matiss Janis Aboltins
8fcaff8e3a Fix display of link schedules condition (#5529) 2025-08-09 16:34:02 +01:00
dependabot[bot]
13bc99738f Bump tmp from 0.2.1 to 0.2.4 (#5504)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.1 to 0.2.4.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.1...v0.2.4)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-09 01:39:06 -04:00
Junyuan Zheng
959824d317 [BUG] Fix incorrect mathematic calculation in reconcile window (#5528)
* [Bug 5527] Fix incorrect mathmatic equation in resoncile window

* fix linter

* update

* add note

* [autofix.ci] apply automated fixes

* fix linter

* fix linter & type

* fix typing error

* fix lint and typing

* add test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-09 01:26:06 -04:00
ShayanAraghi
2abc144b03 Add fix for unfocusing the date disabling all other buttons (#5519)
* Add fix for unfocusing the date disabling all other buttons

* Add Release notes
2025-08-08 11:41:12 -07:00
dgliwka
71250f5fb7 [WIP] add Polish Złoty currency (#5472)
* add Polish Złoty currency

* Add release note

* Add Polish Złoty currency

* Delete upcoming-release-notes/5672.md
2025-08-08 09:40:52 -07:00
Michael Süssemilch
c5f050f6f8 fix: negative numbers in summary report (#5520)
* fix: negative numbers in summary report

* doc: add release notes
2025-08-08 12:32:48 -04:00
r1ch
0d46e221f9 Fix missing space on MergeUnusedPayeesModal (#5523)
* Missing space MergeUnusedPayeesModal.tsx

Create PR branch

* Create 5523.md

* [autofix.ci] apply automated fixes

* Update MergeUnusedPayeesModal.tsx

* Add parent

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-08 09:31:55 -07:00
Matiss Janis Aboltins
6bf2f581a3 Extracted rule editor outside of the modal and into its own component (#5518) 2025-08-07 22:38:44 +01:00
POGMAN
3c34603111 Add 'Prior year to date' time range (#5419)
* add 'Last year to date' time range

* Rename 'Last YTD' to 'Prior YTD'

* Day accurate prevYear for Prior YTD

* Update VRT
2025-08-07 13:31:12 -07:00
milanalexandre
3e488ae8f7 Fix translate payees page (#5514)
* Payees page

* edit rule

format

* add relese note

* feed back

---------

Co-authored-by: Alex <Alex>
2025-08-07 15:51:25 -04:00
Emil George
bacf3091b6 [4036] - Fix Payee Name Overflow (#5489)
* fix: update payee name overflow styles

* chore: add release note

* fix: use text one line component
2025-08-07 10:16:05 -07:00
Emil George
ac77c0f360 Add Indian Rupee (INR) Currency (#5483) 2025-08-07 11:49:21 -04:00
r1ch
e21256e7a2 [WIP] Fix display of old payee name in MergeUnusedPayees modal (#5485) 2025-08-07 09:35:16 -04:00
Joel Jeremy Marquez
22237d11ca Add Philippine Peso currency (#5507) 2025-08-06 17:39:49 -07:00
milanalexandre
63604c1161 Fix translate custom report and rule (#5486)
* translate Allocation Methods

* fix translate Action

translate Split info

* translate date range

* translate monthly spending

* translate shorter filter

* translate date filter reports custom

* add relese note

---------

Co-authored-by: Alex <Alex>
2025-08-06 15:14:36 -07:00
Michael Süssemilch
74b95ca83e feat(currency): add to reports (#5283)
* feat(currency): add to networth report

* feat(currency): add to cashflow report

* feat(currency): add to spending report

* feat(currency): add to summary report

* feat(currency): add to calendar report

* feat(currency): add to custom report

* chore: add release note

* chore(rename): rename formatFunc to format

* fix: missing average rounding

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-08-06 18:05:37 -04:00
Matiss Janis Aboltins
6a9028464b Mobile rules page (#5390) 2025-08-06 23:00:08 +01:00
Matt Fiddaman
186d417c6e make bulk notes prepend/append behaviour consistent with rules (#5464) 2025-08-06 16:26:10 -04:00
Alex Camilleri
c898116412 Added Länsförsäkringar bank (#5479)
* Added Länsförsäkringar

* Release Notes

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2025-08-06 21:21:27 +01:00
Matt Fiddaman
85bd6bfb81 hide hold for next month option when "to budget" is negative (#5496) 2025-08-06 13:12:58 -04:00
Matiss Janis Aboltins
72616376e2 Enhance Tooltip component with close delay functionality (#5475) 2025-08-06 08:49:20 +01:00
Matt Fiddaman
136ad055f7 ♻️ bump sync-server dependencies (#5497)
* bcrypt 5.0.2 -> 6.0.0

* supertest 6.3.4 -> 7.1.4

* pluggy-sdk 0.70.1 -> 0.74.0

* types

* better-sqlite3 11.10.0 -> 12.2.0

* express-rate-limit 7.5.0 -> 8.0.1

* babel/core 7.27.1 -> 7.28.0

* typescript 5.8.3 -> 5.9.2

* node types

* note

* typechecker
2025-08-06 02:00:04 -04:00
Matt Fiddaman
c621f68e0a fix live report date mode not supporting a 2 month window (#5495)
* allow live report date ranges 2 months wide

* note
2025-08-05 23:19:54 -04:00
Matt Fiddaman
4f611ca458 fix (#5493) 2025-08-05 22:12:11 -04:00
Matt Fiddaman
d98e8375a8 merge automatic package versioning GitHub workflows (#5480)
* merge bump-package-versions script with get-next-package-version

* note

* appease the rabbit

* add update flag to node script

* use update flag in workflow

* not much has changed but they live underwater
2025-08-05 22:06:37 -04:00
passabilities.eth
4f50c1a889 Display Schedule Name in Upcoming Tx Notes (#5482)
* show schedule name in notes for upcoming scheduled transactions

* release notes
2025-08-05 21:36:34 -04:00
Matiss Janis Aboltins
0fa582b3d3 feat: show/hide balance graph in side nav (#5452) 2025-08-05 21:35:33 +01:00
Matt Fiddaman
80cd2cf347 prevent closing an account into itself (#5462)
* prevent closing account into itself

* note

* prevent at API level too
2025-08-05 15:11:01 -04:00
milanalexandre
f328332ab2 fix translations on the "new transaction" and "list" page on mobile (#5471)
* fix 'frome'/'to' && 'Split'...

* add relese note

* fomat

---------

Co-authored-by: Alex <Alex>
2025-08-05 01:15:45 -04:00
Chai
7465bdb54d fix: missing table column on mobile devices (#5126)
* fix: missing table column

* chore: better releasenote

* chore: incorrect filename

* 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-08-04 21:31:01 -04:00
milanalexandre
1ad3406e84 Fix/i18n translate status and date (#5411)
* add translations for StatusLabel

* add translations for BalanceHistoryGraph (date)

* add random translations

* Update setHoveredValue update to avoid a crash

format

* add relese note

* feed back

---------

Co-authored-by: Alex <Alex>
2025-08-04 20:48:41 -04:00
Matt Fiddaman
48166952ce add Qatari Riyal currency symbol (#5456)
* add Quatari Riyal currency symbol

* note
2025-08-04 20:43:03 -04:00
Matt Fiddaman
994b959050 parallelise contributor points calculations (#5465) 2025-08-04 18:46:33 -04:00
Çağdaş Şenel
7f4f5005a5 Add currencies SEK and TRY (#5477)
* Add SEK and TRY

* Add SEK and TRY to translations

* Add release notes
2025-08-04 20:09:29 +01:00
ShayanAraghi
1e05b169c8 Truncate multiple payee names with ellipsis to prevent layout overflow in the payee column. (#5418)
* Have the multiple payee text overflow with ellipsis

* [autofix.ci] apply automated fixes

* Add release notes

* add minWidth to the payee text

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-04 11:28:07 -07:00
POGMAN
c54a5b3405 Use null as tag color by default (fallback to theme color) and stricter tag validation (#5398)
* Use null as tag color by default (fallback to theme color) and stricter tag validation

* Set ColorPicker's defaultValue props

* Set default tag color in first position

* Make ColorSwatchPicker configurable

Easier to change colors and how they are presented
2025-08-04 11:27:44 -07:00
milanalexandre
5c11a0a51a Fix missing translations (#5396)
* add missing translations

* add relese note

* add missing translations (no note)

---------

Co-authored-by: Alex <Alex>
2025-08-04 11:26:17 -07:00
milanalexandre
8f69669cc6 Fix translate rule stages (#5421)
* add translation support for rule stages

* add relese note

* fix test

* feed back

---------

Co-authored-by: Alex <Alex>
2025-08-04 11:25:56 -07:00
Matt Fiddaman
9ebdba27fd import category notes from nYNAB (#5461)
* import category notes from nYNAB

* note

* typecheck

* add notes to test file
2025-08-04 14:24:42 -04:00
Jakub Kuczys
8df3d23e03 Allow Docker image build by workflow dispatch on forks (#5404)
* Allow Docker image build by workflow dispatch on forks

* Add release note
2025-08-04 14:23:59 -04:00
ShayanAraghi
5d238c238d fix: move add specific days onto the next row and dynamically increased the width of the numbered input (#5406)
* fix: Move Add Specific Days to next line and dynamically increase the width of the numbered input box

* [autofix.ci] apply automated fixes

* Add Release Notes

* Update the release notes summary

* Update width from px to ch

* Update schedule date modal to expand when repeat every number increases

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-04 14:23:37 -04:00
Julian
e69235a35b fix responsiveness of mobile navigation with longer translation strings (#5429)
* fix responsiveness of mobile navigation with longer translation strings

* [autofix.ci] apply automated fixes

* Update VRT

* 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>
2025-08-04 14:23:26 -04:00
Matt Fiddaman
611f7b046a make payeeName fall back to default if the custom mapped bank sync field is missing (#5460)
* payeeName should fallback if custom mapped field is empty

* note
2025-08-04 14:23:14 -04:00
Matt Fiddaman
ee8f7453ba add tooltip for detailed last sync time on bank sync page (#5458) 2025-08-04 14:23:04 -04:00
Matt Fiddaman
380fae1ccd fix tag colors when using the light theme as system default (#5457)
* fix tag colouring on system default light theme

* note
2025-08-04 14:22:51 -04:00
Matt Fiddaman
8d84f16604 improvements to release PR points counting logic (#5450)
* do not credit reviewers of release PRs

* make release regex more flexible
2025-08-02 15:31:04 -04:00
Michael Clark
239a087542 🐛 Paginate the count points list files call (#5448)
* paginate the count points list files call

* fix tier assignment

* paginate the count points list files call

* fix tier assignment

* ensure utc dates and fix issue filtering to include issues updated in other months
2025-08-02 18:33:18 +01:00
github-actions[bot]
895d69f875 🔖 (25.8.0) (#5447)
* 🔖 (25.8.0)

* 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>
2025-08-02 11:39:18 -04:00
Matt Fiddaman
1cb5e97fab fix reconcile input being hidden when empty (#5446)
* fix bug when reconcile box is empty

* note
2025-08-01 22:00:02 -04:00
Matt Fiddaman
13bd08d243 clarify ai release note comment (#5440) 2025-08-01 17:03:20 -04:00
ShayanAraghi
d946852ee9 Clear active edit on mobile when clicking out of notes field (#5430)
* clear active edit on mobile when clicking out of notes field

* Add release notes
2025-08-01 00:19:52 -04:00
Julian
4cdff76547 fix crash when enter is used to submit empty date in date picker (#5423) 2025-07-31 20:19:54 -07:00
youngcw
1a1975b5ab fix auto hold on new categories (#5434) 2025-07-31 20:18:09 -07:00
youngcw
6e2154d401 Revert "Mobile running balance" because its causing extreme slowness in large accounts. (#5405)
* Revert "Mobile running balance (#5219)"

This reverts commit a20805bfae.

* Add release notes for PR #5405

* retain added translation

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-30 12:57:28 -07:00
POGMAN
45bfd23daa Do not search tags in deleted transactions (#5409) 2025-07-28 07:17:03 -07:00
POGMAN
c44b32805f fix tag matching of filter and make hasTags op case-sensitive (#5386)
Co-authored-by: youngcw <calebyoung94@gmail.com>
2025-07-26 09:03:48 -07:00
Matiss Janis Aboltins
45530638fe Fix potential undefined points issue in contributor points calculation (#5393) 2025-07-26 07:48:09 +01:00
youngcw
a15ff85c20 Fix tags not syncing properly (#5387)
* start

* migration

* don't show deleted tags in list

* tag updates (#5389)

* upsert tag to ensure uniqueness

and insert default-tag in migration

* remove ability to change default tag color

---------

Co-authored-by: pogman-code <adrian.maurin@gmail.com>

* tags fixes (#5391)

* better tag validation

* fix typecheck issues

* update note

---------

Co-authored-by: pogman-code <adrian.maurin@gmail.com>
2025-07-25 14:32:52 -07:00
Matiss Janis Aboltins
1861060bda Fix undefined points error in contributor points (#5392) 2025-07-25 21:14:29 +01:00
Koen van Staveren
9281acb819 fix: reconcile select number on click (#5373) 2025-07-23 14:50:17 -07:00
youngcw
dc86441809 Fix template priority sorting (#5380)
* fix

* lint
2025-07-23 14:48:32 -07:00
passabilities.eth
78bcac95ed [Enhancement] Disable Sidebar Tooltip on Touchscreen Device (#5362)
* check if device is not touchscreen to show account sidebar tooltip

* release notes
2025-07-23 18:34:34 +01:00
POGMAN
45610bae81 Add button to import existing tag from transactions notes (#5368)
* Add button to import existing tag from transactions notes

* fix ##non-tag matching

* 'find' tags instead of 'import' to avoid confusion

* add link to show transaction that have given tag

* use same style as PayeeTableRow's button
2025-07-23 09:57:43 -07:00
Christian Speich
f5a6700b21 Only use fund from local group during cleanup. (#5377)
When using local group cleanup, currently all funds available in To Budget are distributed into the sinks. This behaviour seems somwhat odd and also results in all later groups or the "global" cleanup run to possibly have no funds as they've been distributed before.

This patch now changes the group usage of cleanup, to only distribute funds made available by the group itself. If one also wants to sink money from To Budget into the category, a simple addtional `#cleanup sink`, will achieve this.

Fixes #5374

Signed-off-by: Christian Speich <christian@spei.ch>
2025-07-23 08:56:21 -07:00
Michael Clark
8cea059834 VRT's for Help menu (#5371)
* add some tests for help menu

* updating tests

* release notes

* fix lint

* Update VRT

* unneeded focus

* good wabbit

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-23 09:06:28 +01:00
dependabot[bot]
31b31f2edb bump form-data from 4.0.0 to 4.0.4 (#5365)
* Bump form-data from 4.0.0 to 4.0.4

Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

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-07-22 16:07:36 -04:00
Michael Clark
4b56e0cb54 ⌨️ Enhanced keyboard shortcuts modal (#5340)
* start to an enhanced keyboard shortcuts modal

* shortcut start

* poc

* still rough but getting closer

* refactor a bit

* types

* bits

* renaming

* clarifying comment

* release notes

* suggestions

* adding initial focus state on the search

* fix lint
2025-07-22 18:48:16 +01:00
Matt Fiddaman
f8dd4897e7 fix datepicker month input validation to allow mm/yyyy on resubmission (#5356)
* fix datepicker month input validation

* note

* fix year too

* keep mm/yyyy format consistent

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-22 12:27:19 -04:00
Matt Fiddaman
526d7a9baa add options to post schedule today or the next scheduled date (#5359)
* add option to post transaction today

* note

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2025-07-22 12:27:10 -04:00
Ciftci
af8f26f200 Fixes #5343: Color of the resize handle now adapts to color scheme (dark theme/midnight theme/light theme) (#5364)
* Added color variable pageTextLight to resizable handlers in overview.scss

* Generated release notes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: seabeeberry <seri@Mac.fritz.box>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-21 22:32:24 -04:00
Elijah Olmos
548f36e8d6 feat(CommandBar): display account balances (#5355)
* feat(CommandBar): display account balances

* add release note

* rename release note
2025-07-21 12:36:05 -04:00
Matt Fiddaman
9aed1f400c fix crash when using bulk amount update (#5360) 2025-07-21 11:27:09 -04:00
Matt Fiddaman
59bcee9369 fix payee and transaction context menus when no items selected (#5358)
* fix context menus with no items selected

* note
2025-07-21 10:16:36 -04:00
Matt Fiddaman
ff7529f6e5 remove unused dependency express-response-size (#5361)
* remove unused dep

* note
2025-07-21 10:11:32 -04:00
Julian Dominguez-Schatz
77e99af297 Convert SelectLinkedAccountsModal to TypeScript (#5059)
* Convert `SelectLinkedAccountsModal` to TypeScript

* Add release notes

* Fix typo caught by Rabbit
2025-07-21 09:54:27 -04:00
POGMAN
82a3c97222 Add colors to ReportTable "Totals" row (#5104)
* add colors to ReportTable

* Update VRT

* use default color for zero values

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-21 02:23:30 -04:00
martijnhielema
cafcc823cb Add extra date format (#5353)
* Added dd-mm-yyyy date format for display.

* adding release note entry
2025-07-21 00:23:01 -04:00
passabilities.eth
582e27dbdb [Feature] Only Show Sidebar Tooltip on Desktop (#5352)
* only show sidebar tooltip on desktop

* release notes

* Update upcoming-release-notes/5352.md

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

* replace with media query checks

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-07-21 00:00:19 -04:00
Elijah Olmos
d747563915 feat(CommandBar): add aggregated acct views (#5348)
* feat(CommandBar): add aggregated acct views

* docs: generate release notes

* capitalize release note

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

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-07-19 18:14:06 -04:00
Ngoc Do
84dec137bb fix defaultCleared options not used when importTransactions (#5247) 2025-07-19 11:33:08 -04:00
Guillaume Taquet Gasperini
76de8bf67f Fix Boursorama GoCardless transaction ordering (#5344)
I wrongly thought that all the card transactions will always
have their first line with the `CARTE` identifier.
But as I have seen recently, it's not the case, and we shouldn't
rely on the ordering of the array returned by the Boursorama
GoCardless integration.

Thus, check for transaction patterns in all of the lines of the
unstructured array.

This addresses a true case (added in test) where the payee name
was wrongly extracted as being `110,04 Gbp / 1 Euro = 0,860763454`
2025-07-19 04:23:10 -04:00
passabilities.eth
5db7026435 fix: Save Account Notes (#5326)
* replace static function with useCallback

occasionally if you are editing the notes of an account and then open another account from the sidebar without first closing the popover, the note would be saved to both the current and previously opened account.

* reset tempNote on id change

* save notes only if changes are made

Previously, the notes were being saved unconditionally when closing the Notes modal. This update ensures that the save action is triggered only if the temporary notes differ from the existing note, reducing unnecessary events.
2025-07-18 19:00:26 +01:00
youngcw
e4a993ad67 🐛 improve budgeting of schedules templates (#5319)
* fix

* fix

* didn't need that

* prevent div by 0
2025-07-17 12:49:00 -07:00
Dreptschar
38ab63638a add action button to trigger applyBudgetTemplatesInGroup to mobile (#5293)
* add action button to trigger applyBudgetTemplatesInGroup

* add release note

* fix typos

* fix lint and typecheck findings

* [autofix.ci] apply automated fixes

* fix typecheck

---------

Co-authored-by: dreptschar <dreptschar@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2025-07-16 09:50:03 -07:00
POGMAN
4eea349966 fix pseudo-element quotes in ColorPicker (#5335)
* fix pseudo-element quotes in ColorPicker

* Update upcoming-release-notes/5335.md

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

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-07-16 10:53:19 -04:00
Michael Clark
f66baeabd3 Add command palette to shortcuts modal (#5334)
* add command bar to shortcuts modal

* release notes

* release note
2025-07-15 19:48:16 +01:00
Joshua Taillon
19180138bb Add keyboard shortcuts to duplicate transaction, edit amount, and edit date (#5330)
* add kb shortcuts for duplicate, edit date, and edit amount

* release notes

* only allow editing amount if we can't merge

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-14 21:37:47 +01:00
youngcw
7705a2df08 🐛 fix template priorities in the tracking budget when templating income (#5316) 2025-07-12 07:29:15 -07:00
Sai Pratyush Cherukuri
202af094af enhancement: add interval selector for net worth report (#5282)
* feat: add interval selector for net worth graph

* docs: add release notes

* chore: reformat code

* fix: resolve review comments

* Update VRT

* chore: dummy commit

* feat: handle intervals in net worth card

* fix: handle date ranges inclusively

* fix: enhance date formatting and normalise x-axis tick size

* [autofix.ci] apply automated fixes

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

* Update VRT

* feat: move interval selector inline

* fix: change label to current interval

* Update VRT

* feat: header wrapping for networth, calendar, cashflow and summary

* Update VRT

---------

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: youngcw <calebyoung94@gmail.com>
2025-07-11 12:28:34 -07:00
Margarida Reis
333e7ff7bc fix: apply context menu actions to all selected transactions and payees (#5269)
* fix: apply context menu actions to all selected transactions

when multiple transactions were selected and the user right-clicked on one transaction, context menu actions were only being applied to the right-clicked transaction instead of all selected transactions

* fix: apply context menu actions to all selected payees

when multiple payees were selected and the user right-clicked on one payee, context menu actions were only being applied to the right-clicked payee instead of all selected payees
2025-07-11 12:28:18 -07:00
Giorgio Grigolo
eb11e14e94 Added Bank of Valletta as a limited history bank (#5309) 2025-07-09 21:06:44 -04:00
youngcw
dbcfb63857 [Goals] limits for remainders (#5301)
* limits for remainders

* round if needed

* handle edge cases
2025-07-09 07:11:11 -07:00
youngcw
64f4d200dd Reword the overspending banner for tracking budget. (#5307)
* no hidden

* changes for tracking

* note and cleanup

* sentance case

* exclude income categories

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-08 20:02:50 -07:00
Julian Dominguez-Schatz
4be7e03570 Fix a few typos mentioned on Weblate (#5211)
* Fix a few typos mentioned on Weblate

* Add release notes

* Update VRT

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-07-08 21:05:00 -04:00
youngcw
ca969cc61b no hidden (#5305) 2025-07-08 10:29:19 -07:00
youngcw
682d439c34 [Goals] upgrade week template to be a periodic template (#5295)
* upgrade week template to periodic template

* note

* do it right

* tabs
2025-07-08 06:54:12 -07:00
youngcw
37d91a90f7 [Goals] Round template amounts if hide decimals is set (#5288)
* round amounts

* cleanup, handle not pref set

* move comment

* fix test

* bunny, add a test
2025-07-08 06:53:45 -07:00
Michael Clark
3a09d91399 :electron: Fix early server-started signification message (#5303)
* fix issue where server was telling parent process it was ready but was not finished listening

* release notes
2025-07-08 09:39:42 +01:00
POGMAN
405c8b986f Customize tags colors (#5032)
* add user defined tag colors

* use DB to store tag colors

* change specific tags_colors to generic tags

* move tag customization to its own page (pt. 1)

* move tag customization to its own page (pt. 2), edit description

* move tag customization to its own page (pt. 3), better default tag mgmt

* move tag customization to its own page (pt. 4), tag creation

* move tag customization to its own page (pt. 5), remove tags settings

* Update VRT

* nitpicking & code rabbit fixing

* remove spaces from tag and better partial Tag typing

* add tag similar to transaction add

* peer review updates (live preview)

* enable keyboard navigation

* fix lint errors

* live input for color picker

* disable 3 digit hex color live input

* add context menu

* add tags link to command bar

* Update VRT

* Update VRT

* fix lint issues

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2025-07-07 12:42:26 -07:00
Rapha149
bd9f0aec89 Run rules on transactions created by a transfer. (#5279)
* Run rules on transactions created by a transfer.

* [autofix.ci] apply automated fixes

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

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-07 10:10:16 -07:00
spezzino
a4f0d5bca8 add notes to scheduled transactions table (#5290) 2025-07-07 10:10:01 -07:00
Michael Clark
0085c3b58a 💬 Added Discord link to the help menu for discoverability (#5286)
* adding our discord to the help menu so users can find community support

* better label

* derp

* release note

* derp
2025-07-04 21:48:02 +01:00
Ciftci
cfb0f51e36 Fixes #5278: Applied PrivacyFilter to balance graph display (and added conditionality whether user is hovering on the graph) (#5281)
* Apply PrivacyFilter to balance graph display

* Deleted empty line which was added by accident

* Added release note

* [autofix.ci] apply automated fixes

* isHovered state now conditionally deactivates PrivacyFilter if user hovers the graph

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Seray Ciftci <seri@Mac.fritz.box>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-04 12:53:44 -07:00
Matt Fiddaman
2d95fe6d03 add some basic linting rules for translation consistency (#5212)
* lint rules

* trans rule

* migrate away from rulesDir config

* fixes

* prefer Trans to t()

* typechecker

* cleanup

* note

* typo

* extend regex to include punctuation

* extend fixer to handle imports

* autofixes from punctuation
2025-07-04 15:17:17 -04:00
Matiss Janis Aboltins
80bb4ab299 Update contributor points calculation logic (#5169) 2025-07-04 07:56:54 +01:00
Michael Süssemilch
2f71e007d6 feat(currency): add currency setting and format (#5167) 2025-07-04 07:56:23 +01:00
Julian Dominguez-Schatz
7b71374e79 Move remaining .d.ts files to .ts (#5208)
* Update CRDT protobuf generation to create a .ts file

* Move remaining .d.ts files to .ts

* Fix remaining type errors

* Ignore .d.ts files so we don't accidentally use them in the future

* Add release notes
2025-07-03 14:59:21 -04:00
Matt Fiddaman
c1d70722b8 use correct running balance when adding a new transaction (#5207) 2025-07-03 13:16:03 -04:00
POGMAN
2cd79960a9 Fix rule templating on date causing crash (#5259)
* fix rule templating on date causing crash

* apply coderabbit suggestion, more or less
2025-07-03 09:27:16 -07:00
Ciftci
9a6afda6de Fixes #5228 - has tag(s) filter now takes values when pressing Enter (#5263)
* Fixes #5228 - has tag(s) filter now takes values when typing quickly and pressing Enter

* Added release notes

* State of GenericInput now gets updated with every keystroke

---------

Co-authored-by: Seray Ciftci <seri@Mac.fritz.box>
2025-07-03 09:27:04 -07:00
lougeorge
979fa43c4a correctly ignore hidden categories when using "Set Average Budget" (#5239)
* fixed setAverage functions to ignore hidden categories

* Add logic to ignore hidden categories in budget actions

* Modify SQL statements in setAverage functions to also exclude categoryGroups and change DbViewCategory type definition to include hidden flag of the group a category belongs to

* [autofix.ci] apply automated fixes

* Revert setZero functionality to set all budget categories and category groups to zero, including hidden ones

* Add new type DbViewCategoryWithGroupHidden which includes a flag for whether the group a category belongs to is hidden or not, and have setAvg functions use that type instead

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-03 09:26:47 -07:00
youngcw
a20805bfae Mobile running balance (#5219)
* start

* small fix

* clean

* working for regular transactions

* working for schedules

* cleanup

* typing

* cleanup

* cleanup

* vrt

* bunny

* use pref

* use pref right, lint

* more lint

* vrt

* pass hasInitialBalances to isLoading

* remove comment

* Add option to calculate running balances in useTransactions hook

* Fix typecheck error

* Fix lint error

* use the updated hook

* typecheck

* simplify

* don't show balances when searching

* Add runningBalances to usePreviewTransactions and an option to set the starting balance to start running balance calculation from

* Add filter to usePreviewTransactions and set startingBalance to account and category preview transaction hooks

* use runningbalance from preview transactions hook

* lint

* lint;typecheck

* remove initial from preview balances

* remove unneeded type

* Apply suggestions from code review

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>

* typecheck; align right; change color

* types

* add a menu item

* cleanup

* fix for loot-core migrated files

* lint;type

* fix import

* only schedules need fixed

* lint

* it works

* cleanup

* make lint happy

* [autofix.ci] apply automated fixes

* simplify a bit

* fix import

* feedback

* fixed regular transaction balance calculation

* fix numbers not showing

* fix schedule running balance

* note

* attempt to update properly

* type

* remove the useEffect that I don't think should be requred

* remove old note

* cleanup

* typeing

* I FINALLY FOUND THE PROBLEM

* cleaner balance calculation

* fixes

* fix zeros

---------

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-07-03 09:26:28 -07:00
845 changed files with 105121 additions and 11981 deletions

View File

@@ -0,0 +1,12 @@
---
alwaysApply: true
---
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
The following commands can be useful:
- `yarn typecheck` to run typechecker
- `yarn lint` to run the code linter and formatter
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
- `yarn test` to run all the tests

View File

@@ -1,24 +0,0 @@
---
description:
globs:
alwaysApply: true
---
Before pushing code changes or opening a pull request, follow these steps:
1. Check if your branch already has a changelog file in the "upcoming-release-notes" folder.
2. If there is no changelog file for your branch:
a. Find the number of the most recent (highest-numbered) open issue or pull request on GitHub.
b. Increment that number by 1. Use this as the filename for your new changelog file.
c. Create a new markdown file in the "upcoming-release-notes" folder with the following format:
```
---
category: Features OR Maintenance OR Enhancements OR Bugfix
authors: [$GithubUsername]
---
$Description
```
3. Commit the new changelog file.
4. Proceed with your push or pull request.

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`.
@@ -30,3 +31,7 @@ Syntax and Formatting
- Use the "function" keyword for pure functions.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use declarative JSX, keeping JSX minimal and readable.
Change validation
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct

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

@@ -57,7 +57,8 @@ async function commentOnPR() {
`**Summary:** ${summaryData.summary}`,
`**File:** [upcoming-release-notes/${summaryData.prNumber}.md](${fileUrl})`,
'',
'The release notes file has been committed to the repository. You can edit it if needed before merging.',
// 'The release notes file has been committed to the repository. You can edit it if needed before merging.',
"If you're happy with this release note, you can add it to your pull request. If not, you'll need to add your own before a maintainer can review your change.",
].join('\n');
await octokit.rest.issues.createComment({

View File

@@ -1,53 +0,0 @@
#!/bin/bash
set -euo pipefail
if [ "$#" -gt 0 ]; then
version="${1#v}"
else
version=""
fi
files_to_bump=(
packages/api/package.json
packages/desktop-client/package.json
packages/desktop-electron/package.json
packages/sync-server/package.json
)
for file in "${files_to_bump[@]}"; do
if [ -z "$version" ]; then
# version format: YY.MM.patch
version="$(jq -r .version "$file" | perl -e '
($y,$m,$p)=split(/\./,<>);
($sec,$min,$hour,$day,$mon,$year)=localtime();
$year -= 100; # Perl year starts at 1900
$mon++; # Adjust 0-indexed month to 1-indexed
if ($y == $year && $m == $mon) {
if ($day <= 25) {
# Patch release for the current month
$p++;
} else {
# Use next month for a new release period
$p = 0;
$m++;
$m > 12 && ($m=1, $y++);
}
} else {
# Use the current date for a new release period
$y = $year;
$m = $mon;
$p = 0;
}
print "$y.$m.$p\n";
')"
if [ -z "$version" ]; then
echo "Error: Failed to calculate new version" >&2
exit 1
fi
fi
echo "Bumping $file to version $version"
jq '.version = "'"$version"'"' "$file" > "$file.tmp"
mv "$file.tmp" "$file"
done

View File

@@ -1,94 +0,0 @@
#!/usr/bin/env node
// This script is used in GitHub Actions to get the next version based on the current package.json version.
// It supports three types of versioning: nightly, hotfix, and monthly.
const { parseArgs } = require('node:util');
const fs = require('node:fs');
const args = process.argv;
const options = {
'package-json': {
type: 'string',
short: 'p',
},
type: {
type: 'string', // nightly, hotfix, monthly
short: 't',
},
};
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
if (!values['package-json']) {
console.error(
'Please specify the path to package.json using --package-json or -p option.',
);
process.exit(1);
}
try {
const packageJsonPath = values['package-json'];
// Read and parse package.json
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version;
// Parse year and month from version (e.g. 25.5.1 -> year=2025, month=5)
const versionParts = currentVersion.split('.');
const versionYear = parseInt(versionParts[0]);
const versionMonth = parseInt(versionParts[1]);
const versionHotfix = parseInt(versionParts[2]);
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const nextVersionYear = nextVersionMonthDate
.getFullYear()
.toString()
.slice(-2);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
// Get current date string
const currentDate = new Date()
.toISOString()
.split('T')[0]
.replaceAll('-', '');
switch (values.type) {
case 'nightly': {
const newVersion = `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDate}`;
process.stdout.write(newVersion); // return the new version to stdout
process.exit();
}
case 'hotfix': {
const bugfixVersion = `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
process.stdout.write(bugfixVersion); // return the bugfix version to stdout
process.exit();
}
case 'monthly': {
const stableVersion = `${nextVersionYear}.${nextVersionMonth}.0`;
process.stdout.write(stableVersion); // return the stable version to stdout
process.exit();
}
default:
console.error(
'Invalid type specified. Use "nightly", "hotfix", or "monthly".',
);
process.exit(1);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}

View File

@@ -1,5 +1,8 @@
import { Octokit } from '@octokit/rest';
import { minimatch } from 'minimatch';
import pLimit from 'p-limit';
const limit = pLimit(50);
/** Repository-specific configuration for points calculation */
const REPOSITORY_CONFIG = new Map([
@@ -10,9 +13,10 @@ const REPOSITORY_CONFIG = new Map([
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 0,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 1000, points: 6 },
{ minChanges: 100, points: 4 },
{ minChanges: 0, points: 2 },
{ minChanges: 500, points: 8 },
{ minChanges: 100, points: 6 },
{ minChanges: 10, points: 2 },
{ minChanges: 0, points: 1 },
],
EXCLUDED_FILES: [
'yarn.lock',
@@ -29,8 +33,8 @@ const REPOSITORY_CONFIG = new Map([
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 1000, points: 6 },
{ minChanges: 100, points: 4 },
{ minChanges: 2000, points: 6 },
{ minChanges: 200, points: 4 },
{ minChanges: 0, points: 2 },
],
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
@@ -45,24 +49,25 @@ const REPOSITORY_CONFIG = new Map([
function getLastMonthDates() {
// Get data relating to the last month
const now = new Date();
// Always use UTC for calculations
const firstDayOfLastMonth = new Date(
now.getFullYear(),
now.getMonth() - 1,
1,
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0),
);
const since = process.env.START_DATE
? new Date(process.env.START_DATE)
? new Date(Date.parse(process.env.START_DATE))
: firstDayOfLastMonth;
// Calculate the end of the month for the since date
// Calculate the end of the month for the since date in UTC
const until = new Date(
since.getFullYear(),
since.getMonth() + 1,
0,
23,
59,
59,
999,
Date.UTC(
since.getUTCFullYear(),
since.getUTCMonth() + 1,
0,
23,
59,
59,
999,
),
);
return { since, until };
@@ -75,7 +80,9 @@ function getLastMonthDates() {
* @returns {number} The total points earned for the repository
*/
async function countContributorPoints(repo) {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const owner = 'actualbudget';
const config = REPOSITORY_CONFIG.get(repo);
@@ -122,128 +129,134 @@ async function countContributorPoints(repo) {
// Get all PRs using search
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
const recentPRs = await octokit.paginate(
octokit.search.issuesAndPullRequests,
'GET /search/issues',
{
q: searchQuery,
per_page: 100,
advanced_search: true,
},
response => response.data,
response => response.data.filter(pr => pr.number),
);
// Get reviews and PR details for each PR
for (const pr of recentPRs) {
const { data: reviews } = await octokit.pulls.listReviews({
owner,
repo,
pull_number: pr.number,
});
// Get list of modified files
const { data: modifiedFiles } = await octokit.pulls.listFiles({
owner,
repo,
pull_number: pr.number,
});
// Calculate points based on PR size, excluding specified files
const totalChanges = modifiedFiles
.filter(
file =>
!config.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern),
await Promise.all(
recentPRs.map(pr =>
limit(async () => {
const [reviews, modifiedFiles] = await Promise.all([
octokit.pulls.listReviews({ owner, repo, pull_number: pr.number }),
octokit.paginate(
octokit.pulls.listFiles,
{
owner,
repo,
pull_number: pr.number,
per_page: 100,
},
res => res.data,
),
)
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
]);
// Check if this is a release PR
const isReleasePR = pr.title.match(/^🔖 \(\d+\.\d+\.\d+\)/);
const totalChanges = modifiedFiles
.filter(
file =>
!config.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern),
),
)
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
// Calculate points for reviewers based on PR size
const prPoints = config.PR_REVIEW_POINT_TIERS.find(
tier => totalChanges > tier.minChanges,
).points;
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
const prPoints =
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
?.points ?? 0;
// Add points to the reviewers
const uniqueReviewers = new Set();
reviews
.filter(
review =>
stats.has(review.user?.login) &&
review.state === 'APPROVED' &&
!uniqueReviewers.has(review.user?.login),
)
.forEach(({ user: { login: reviewer } }) => {
uniqueReviewers.add(reviewer);
const userStats = stats.get(reviewer);
userStats.reviews.push({ pr: pr.number.toString(), points: prPoints });
userStats.points += prPoints;
});
// Award points to the PR creator if it's a release PR
if (isReleasePR && stats.has(pr.user.login)) {
const creatorStats = stats.get(pr.user.login);
creatorStats.reviews.push({
pr: pr.number.toString(),
points: config.POINTS_PER_RELEASE_PR,
isReleaseCreator: true,
});
creatorStats.points += config.POINTS_PER_RELEASE_PR;
}
}
// Get all issues with label events in the last month
const issues = await octokit.paginate(
octokit.issues.listForRepo,
{
owner,
repo,
state: 'all',
sort: 'updated',
direction: 'desc',
per_page: 100,
since: since.toISOString(),
},
(response, done) =>
response.data.filter(issue => new Date(issue.updated_at) <= until),
if (isReleasePR) {
if (stats.has(pr.user.login)) {
const creatorStats = stats.get(pr.user.login);
creatorStats.reviews.push({
pr: pr.number.toString(),
points: config.POINTS_PER_RELEASE_PR,
isReleaseCreator: true,
});
creatorStats.points += config.POINTS_PER_RELEASE_PR;
}
} else {
const uniqueReviewers = new Set();
reviews.data
.filter(
review =>
stats.has(review.user?.login) &&
review.state === 'APPROVED' &&
!uniqueReviewers.has(review.user?.login),
)
.forEach(({ user: { login: reviewer } }) => {
uniqueReviewers.add(reviewer);
const userStats = stats.get(reviewer);
userStats.reviews.push({
pr: pr.number.toString(),
points: prPoints,
});
userStats.points += prPoints;
});
}
}),
),
);
// Get all issues with label events in the last month
const issues = await octokit.paginate(octokit.issues.listForRepo, {
owner,
repo,
state: 'all',
sort: 'updated',
direction: 'desc',
per_page: 100,
since: since.toISOString(),
});
// Get label events for each issue
for (const issue of issues) {
const { data: events } = await octokit.issues.listEventsForTimeline({
owner,
repo,
issue_number: issue.number,
});
await Promise.all(
issues.map(issue =>
limit(async () => {
const { data: events } = await octokit.issues.listEventsForTimeline({
owner,
repo,
issue_number: issue.number,
});
// Process events
events
.filter(
event =>
new Date(event.created_at) > since &&
new Date(event.created_at) <= until &&
stats.has(event.actor?.login),
)
.forEach(event => {
if (
event.event === 'unlabeled' &&
event.label &&
event.label.name.toLowerCase() === 'needs triage'
) {
const remover = event.actor.login;
const userStats = stats.get(remover);
userStats.labelRemovals.push(issue.number.toString());
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
}
events
.filter(event => {
const createdAt = new Date(event.created_at);
return (
createdAt.getTime() > since.getTime() &&
createdAt.getTime() <= until.getTime() &&
stats.has(event.actor?.login)
);
})
.forEach(event => {
if (
event.event === 'unlabeled' &&
event.label?.name.toLowerCase() === 'needs triage'
) {
const remover = event.actor.login;
const userStats = stats.get(remover);
userStats.labelRemovals.push(issue.number.toString());
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
}
if (event.event === 'closed') {
const closer = event.actor.login;
const userStats = stats.get(closer);
userStats.issueClosings.push(issue.number.toString());
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
}
});
}
if (
event.event === 'closed' &&
event.state_reason === 'not_planned'
) {
const closer = event.actor.login;
const userStats = stats.get(closer);
userStats.issueClosings.push(issue.number.toString());
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
}
});
}),
),
);
// Print all statistics
printStats(

View File

@@ -22,9 +22,9 @@ permissions:
env:
IMAGES: |
actualbudget/actual-server
ghcr.io/actualbudget/actual-server
ghcr.io/actualbudget/actual
${{ !github.event.repository.fork && 'actualbudget/actual-server' || '' }}
ghcr.io/${{ github.repository_owner }}/actual-server
ghcr.io/${{ github.repository_owner }}/actual
# Creates the following tags:
# - actual-server:edge
@@ -34,7 +34,7 @@ env:
jobs:
build:
if: ${{ github.event.repository.fork == false }}
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
name: Build Docker image
runs-on: ubuntu-latest
strategy:
@@ -60,7 +60,7 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

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

@@ -24,8 +24,29 @@ jobs:
id: bump_package_versions
shell: bash
run: |
.github/actions/bump-package-versions ${{ github.event.inputs.version }}
echo "version=$(jq -r .version packages/desktop-client/package.json)" > $GITHUB_OUTPUT
declare -A packages=(
[web]="desktop-client"
[electron]="desktop-electron"
[sync]="sync-server"
[api]="api"
)
for key in "${!packages[@]}"; do
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version="${{ github.event.inputs.version }}"
else
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
fi
eval "NEW_${key^^}_VERSION=\"$version\""
done
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@v7
with:

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

@@ -81,7 +81,7 @@ jobs:
base-stats-json-path: ./base/web-stats.json
title: desktop-client
- uses: github/webpack-bundlesize-compare-action@v2.1.0
- uses: twk3/rollup-size-compare-action@v1.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/loot-core-stats.json

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

5
.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
@@ -60,3 +62,6 @@ fly.toml
# TypeScript cache
build/
# .d.ts files aren't type-checked with skipLibCheck set to true
*.d.ts

View File

@@ -7,6 +7,8 @@ packages/api/migrations
packages/crdt/dist
packages/component-library/src/icons/**/*
packages/desktop-client/bundle.browser.js
packages/desktop-client/stats.json
packages/desktop-client/.swc/
packages/desktop-client/build/
packages/desktop-client/locale/
packages/desktop-client/build-electron/
@@ -23,5 +25,6 @@ packages/desktop-electron/dist/
packages/loot-core/**/node_modules/*
packages/loot-core/**/lib-dist/*
packages/loot-core/**/proto/*
packages/sync-server/coverage/
.yarn/*
upcoming-release-notes/*

10
CODEOWNERS Normal file
View File

@@ -0,0 +1,10 @@
# CODEOWNERS file for Actual Budget
# Please add your name to code-paths that you feel especially
# passionate about. You will be notified for any PRs there.
/packages/api/ @MatissJanis
/packages/component-library/ @MatissJanis
/packages/desktop-client/src/components/mobile @joel-jeremy
/packages/desktop-electron/ @MikesGlitch
/packages/loot-core/src/server/budget @youngcw
/packages/sync-server/ @matt-fidd

605
PLUGIN_ARCHITECTURE.md Normal file
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

@@ -6,7 +6,7 @@ import prompts from 'prompts';
async function run() {
const username = await execAsync(
// eslint-disable-next-line rulesdir/typography
// eslint-disable-next-line actual/typography
"gh api user --jq '.login'",
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
);

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

@@ -1,29 +1,15 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import globals from 'globals';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginRulesDir from 'eslint-plugin-rulesdir';
import pluginTypescript from 'typescript-eslint';
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
import tsParser from '@typescript-eslint/parser';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
pluginRulesDir.RULES_DIR = path.join(
__dirname,
'packages',
'eslint-plugin-actual',
'lib',
'rules',
);
const confusingBrowserGlobals = [
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
'addEventListener',
@@ -88,27 +74,27 @@ const confusingBrowserGlobals = [
export default pluginTypescript.config(
{
ignores: [
// Global ignore patterns
'**/node_modules/**',
'**/dist/**',
'**/*.zip',
// Specific ignore patterns
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/dist',
'packages/api/@types',
'packages/api/migrations',
'packages/crdt/dist',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/build/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
'packages/desktop-client/public/data/',
'packages/desktop-client/**/node_modules/*',
'packages/desktop-client/node_modules/',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
'packages/desktop-electron/build/',
'packages/desktop-electron/dist/',
'packages/loot-core/**/node_modules/*',
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/build/',
@@ -167,14 +153,19 @@ export default pluginTypescript.config(
pluginImport.flatConfigs.recommended,
{
plugins: {
'react-hooks': pluginReactHooks,
'jsx-a11y': pluginJSXA11y,
rulesdir: pluginRulesDir,
'typescript-paths': pluginTypescriptPaths,
actual: pluginActual,
},
rules: {
'actual/no-untranslated-strings': 'error',
'actual/prefer-trans-over-t': 'error',
},
},
{
files: ['**/*.{js,ts,jsx,tsx}'],
plugins: {
'jsx-a11y': pluginJSXA11y,
'react-hooks': pluginReactHooks,
},
rules: {
// http://eslint.org/docs/rules/
'array-callback-return': 'warn',
@@ -458,8 +449,9 @@ export default pluginTypescript.config(
},
],
'rulesdir/typography': 'warn',
'rulesdir/prefer-if-statement': 'warn',
'actual/typography': 'warn',
'actual/prefer-if-statement': 'warn',
'actual/prefer-logger-over-console': 'error',
// Note: base rule explicitly disabled in favor of the TS one
'no-unused-vars': 'off',
@@ -467,6 +459,7 @@ export default pluginTypescript.config(
'warn',
{
varsIgnorePattern: '^(_|React)',
argsIgnorePattern: '^(_|React)',
ignoreRestSiblings: true,
caughtErrors: 'none',
},
@@ -639,6 +632,9 @@ export default pluginTypescript.config(
},
{
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
plugins: {
'typescript-paths': pluginTypescriptPaths,
},
rules: {
'typescript-paths/absolute-parent-import': [
'error',
@@ -767,6 +763,18 @@ export default pluginTypescript.config(
'react-hooks/exhaustive-deps': 'off',
},
},
{
files: ['**/*.cjs'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
files: ['**/manifest.ts'],
rules: {
'import/no-default-export': 'off',
},
},
{
files: [
'eslint.config.mjs',
@@ -778,7 +786,9 @@ export default pluginTypescript.config(
],
rules: {
'rulesdir/typography': 'off',
'actual/typography': 'off',
'actual/no-untranslated-strings': 'off',
'actual/prefer-logger-over-console': 'off',
},
},
{
@@ -797,7 +807,7 @@ export default pluginTypescript.config(
// TODO: fix the issues in these files
rules: {
'import/extensions': 'off',
'rulesdir/typography': 'off',
'actual/typography': 'off',
},
},
{
@@ -805,8 +815,6 @@ export default pluginTypescript.config(
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
// can be re-enabled after https://github.com/actualbudget/actual/pull/4253
'@typescript-eslint/no-unused-vars': 'off',
},
},
);

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",
@@ -41,9 +44,10 @@
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "prettier --check . && eslint . --max-warnings 0",
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
@@ -54,32 +58,32 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.15.18",
"@types/node": "^22.18.8",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.32.1",
"cross-env": "^7.0.3",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.3.5",
"eslint-plugin-import": "^2.31.0",
"@typescript-eslint/parser": "^8.45.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-rulesdir": "^0.2.2",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"lint-staged": "^16.2.3",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"p-limit": "^6.2.0",
"prettier": "^3.6.2",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
@@ -98,7 +102,7 @@
},
"packageManager": "yarn@4.9.1",
"browserslist": [
"electron 24.0",
"electron >= 35.0",
"defaults"
]
}

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.7.1",
"version": "25.10.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -24,14 +24,14 @@
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^12.4.1",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^11.1.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.8.3",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

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": {
@@ -48,7 +48,8 @@
"./tokens": "./src/tokens.ts",
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",
"./view": "./src/View.tsx"
"./view": "./src/View.tsx",
"./color-picker": "./src/ColorPicker.tsx"
},
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",

View File

@@ -0,0 +1,180 @@
import { ChangeEvent, ReactNode } from 'react';
import {
ColorPicker as AriaColorPicker,
ColorPickerProps as AriaColorPickerProps,
Dialog,
DialogTrigger,
ColorSwatch as AriaColorSwatch,
ColorSwatchProps,
ColorSwatchPicker as AriaColorSwatchPicker,
ColorSwatchPickerItem,
ColorField,
parseColor,
} from 'react-aria-components';
import { css } from '@emotion/css';
import { Input } from './Input';
import { Popover } from './Popover';
function ColorSwatch(props: ColorSwatchProps) {
return (
<AriaColorSwatch
{...props}
style={({ color }) => ({
background: color.toString('hex'),
width: '32px',
height: '32px',
borderRadius: '4px',
boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)',
})}
/>
);
}
// colors from https://materialui.co/colors
const DEFAULT_COLOR_SET = [
'#690CB0',
'#D32F2F',
'#C2185B',
'#7B1FA2',
'#512DA8',
'#303F9F',
'#1976D2',
'#0288D1',
'#0097A7',
'#00796B',
'#388E3C',
'#689F38',
'#AFB42B',
'#FBC02D',
'#FFA000',
'#F57C00',
'#E64A19',
'#5D4037',
'#616161',
'#455A64',
];
interface ColorSwatchPickerProps {
columns?: number;
colorset?: string[];
}
function ColorSwatchPicker({
columns = 5,
colorset = DEFAULT_COLOR_SET,
}: ColorSwatchPickerProps) {
const pickers = [];
for (let l = 0; l < colorset.length / columns; l++) {
const pickerItems = [];
for (let c = 0; c < columns; c++) {
const color = colorset[columns * l + c];
if (!color) {
break;
}
pickerItems.push(
<ColorSwatchPickerItem
key={color}
color={color}
className={css({
position: 'relative',
outline: 'none',
borderRadius: '4px',
width: 'fit-content',
forcedColorAdjust: 'none',
cursor: 'pointer',
'&[data-selected]::after': {
// eslint-disable-next-line actual/typography
content: '""',
position: 'absolute',
inset: 0,
border: '2px solid black',
outline: '2px solid white',
outlineOffset: '-4px',
borderRadius: 'inherit',
},
})}
>
<ColorSwatch />
</ColorSwatchPickerItem>,
);
}
pickers.push(
<AriaColorSwatchPicker
key={`colorset-${l}`}
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
{pickerItems}
</AriaColorSwatchPicker>,
);
}
return pickers;
}
const isColor = (value: string) => /^#[0-9a-fA-F]{6}$/.test(value);
interface ColorPickerProps extends AriaColorPickerProps {
children?: ReactNode;
columns?: number;
colorset?: string[];
}
export function ColorPicker({
children,
columns,
colorset,
...props
}: ColorPickerProps) {
const onInput = (value: string) => {
if (!isColor(value)) {
return;
}
const color = parseColor(value);
if (color) {
props.onChange?.(color);
}
};
return (
<AriaColorPicker defaultValue={props.defaultValue ?? '#690CB0'} {...props}>
<DialogTrigger>
{children}
<Popover>
<Dialog
style={{
outline: 'none',
padding: '15px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
minWidth: '192px',
maxHeight: 'inherit',
boxSizing: 'border-box',
overflow: 'auto',
}}
>
<ColorSwatchPicker columns={columns} colorset={colorset} />
<ColorField
onInput={({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
onInput(value)
}
>
<Input placeholder="#RRGGBB" style={{ width: '100px' }} />
</ColorField>
</Dialog>
</Popover>
</DialogTrigger>
</AriaColorPicker>
);
}

View File

@@ -1,5 +1,6 @@
import React, { type CSSProperties, type ReactNode } from 'react';
import React, { type ReactNode } from 'react';
import { type CSSProperties } from './styles';
import { View } from './View';
type SpaceBetweenProps = {

View File

@@ -62,7 +62,7 @@ export const Toggle = ({
data-on={isOn}
className={css(
{
// eslint-disable-next-line rulesdir/typography
// eslint-disable-next-line actual/typography
content: '" "',
position: 'absolute',
top: '2px',

View File

@@ -27,8 +27,13 @@ export const Tooltip = ({
const [isHovered, setIsHover] = useState(false);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const handlePointerEnter = useCallback(() => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
const timeout = setTimeout(() => {
setIsHover(true);
}, triggerProps.delay ?? 300);
@@ -41,8 +46,10 @@ export const Tooltip = ({
clearTimeout(hoverTimeoutRef.current);
}
setIsHover(false);
}, []);
closeTimeoutRef.current = setTimeout(() => {
setIsHover(false);
}, triggerProps.closeDelay ?? 0);
}, [triggerProps.closeDelay]);
// Force closing the tooltip whenever the disablement state changes
useEffect(() => {

View File

@@ -23,7 +23,11 @@ export const View = forwardRef<HTMLDivElement, ViewProps>((props, ref) => {
{...restProps}
ref={innerRef ?? ref}
style={nativeStyle}
className={cx('view', className, css(style))}
className={cx(
'view',
className,
style && Object.keys(style).length > 0 ? css(style) : undefined,
)}
/>
);
});

View File

@@ -91,7 +91,7 @@ export const styles: Record<string, any> = {
},
shadowLarge,
tnum: {
// eslint-disable-next-line rulesdir/typography
// eslint-disable-next-line actual/typography
fontFeatureSettings: '"tnum"',
},
notFixed: { fontFeatureSettings: '' },
@@ -154,4 +154,10 @@ export const styles: Record<string, any> = {
borderRadius: 4,
padding: '3px 5px',
},
mobileListItem: {
borderBottom: `1px solid ${theme.tableBorder}`,
backgroundColor: theme.tableBackground,
padding: 16,
cursor: 'pointer',
},
};

View File

@@ -188,6 +188,7 @@ export const theme = {
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
noteTagBackground: 'var(--color-noteTagBackground)',
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
noteTagDefault: 'var(--color-noteTagDefault)',
noteTagText: 'var(--color-noteTagText)',
budgetOtherMonth: 'var(--color-budgetOtherMonth)',
budgetCurrentMonth: 'var(--color-budgetCurrentMonth)',

View File

@@ -18,4 +18,9 @@ protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
../../node_modules/.bin/prettier --write src/proto/*.d.ts
for file in src/proto/*.d.ts; do
{ echo "/* eslint-disable @typescript-eslint/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
rm "$file"
done
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'

View File

@@ -11,17 +11,19 @@
"scripts": {
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node && cp src/proto/sync_pb.d.ts dist/src/proto/",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --globals"
},
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^11.1.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-4",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.8.3",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

View File

@@ -1,4 +1,6 @@
import * as SyncPb from './proto/sync_pb';
/* eslint-disable @typescript-eslint/no-explicit-any */
import './proto/sync_pb.js'; // Import for side effects
export {
merkle,
getClock,
@@ -11,4 +13,11 @@ export {
Timestamp,
} from './crdt';
export const SyncProtoBuf = SyncPb;
// Access global proto namespace
export const SyncRequest = (globalThis as any).proto.SyncRequest;
export const SyncResponse = (globalThis as any).proto.SyncResponse;
export const Message = (globalThis as any).proto.Message;
export const MessageEnvelope = (globalThis as any).proto.MessageEnvelope;
export const EncryptedData = (globalThis as any).proto.EncryptedData;
export const SyncProtoBuf = (globalThis as any).proto;

View File

@@ -157,9 +157,9 @@ proto.EncryptedData.prototype.toObject = function(opt_includeInstance) {
*/
proto.EncryptedData.toObject = function(includeInstance, msg) {
var f, obj = {
iv: msg.getIv_asB64(),
authtag: msg.getAuthtag_asB64(),
data: msg.getData_asB64()
iv: msg.getIv_asB64(),
authtag: msg.getAuthtag_asB64(),
data: msg.getData_asB64()
};
if (includeInstance) {
@@ -419,10 +419,10 @@ proto.Message.prototype.toObject = function(opt_includeInstance) {
*/
proto.Message.toObject = function(includeInstance, msg) {
var f, obj = {
dataset: jspb.Message.getFieldWithDefault(msg, 1, ""),
row: jspb.Message.getFieldWithDefault(msg, 2, ""),
column: jspb.Message.getFieldWithDefault(msg, 3, ""),
value: jspb.Message.getFieldWithDefault(msg, 4, "")
dataset: jspb.Message.getFieldWithDefault(msg, 1, ""),
row: jspb.Message.getFieldWithDefault(msg, 2, ""),
column: jspb.Message.getFieldWithDefault(msg, 3, ""),
value: jspb.Message.getFieldWithDefault(msg, 4, "")
};
if (includeInstance) {
@@ -639,9 +639,9 @@ proto.MessageEnvelope.prototype.toObject = function(opt_includeInstance) {
*/
proto.MessageEnvelope.toObject = function(includeInstance, msg) {
var f, obj = {
timestamp: jspb.Message.getFieldWithDefault(msg, 1, ""),
isencrypted: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
content: msg.getContent_asB64()
timestamp: jspb.Message.getFieldWithDefault(msg, 1, ""),
isencrypted: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
content: msg.getContent_asB64()
};
if (includeInstance) {
@@ -860,12 +860,12 @@ proto.SyncRequest.prototype.toObject = function(opt_includeInstance) {
*/
proto.SyncRequest.toObject = function(includeInstance, msg) {
var f, obj = {
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
proto.MessageEnvelope.toObject, includeInstance),
fileid: jspb.Message.getFieldWithDefault(msg, 2, ""),
groupid: jspb.Message.getFieldWithDefault(msg, 3, ""),
keyid: jspb.Message.getFieldWithDefault(msg, 5, ""),
since: jspb.Message.getFieldWithDefault(msg, 6, "")
fileid: jspb.Message.getFieldWithDefault(msg, 2, ""),
groupid: jspb.Message.getFieldWithDefault(msg, 3, ""),
keyid: jspb.Message.getFieldWithDefault(msg, 5, ""),
since: jspb.Message.getFieldWithDefault(msg, 6, "")
};
if (includeInstance) {
@@ -1140,9 +1140,9 @@ proto.SyncResponse.prototype.toObject = function(opt_includeInstance) {
*/
proto.SyncResponse.toObject = function(includeInstance, msg) {
var f, obj = {
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
proto.MessageEnvelope.toObject, includeInstance),
merkle: jspb.Message.getFieldWithDefault(msg, 2, "")
merkle: jspb.Message.getFieldWithDefault(msg, 2, "")
};
if (includeInstance) {

View File

@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-namespace */
// package:
// file: sync.proto
import * as jspb from 'google-protobuf';
export class EncryptedData extends jspb.Message {
export declare class EncryptedData extends jspb.Message {
getIv(): Uint8Array | string;
getIv_asU8(): Uint8Array;
getIv_asB64(): string;
@@ -48,7 +49,7 @@ export namespace EncryptedData {
};
}
export class Message extends jspb.Message {
export declare class Message extends jspb.Message {
getDataset(): string;
setDataset(value: string): void;
@@ -88,7 +89,7 @@ export namespace Message {
};
}
export class MessageEnvelope extends jspb.Message {
export declare class MessageEnvelope extends jspb.Message {
getTimestamp(): string;
setTimestamp(value: string): void;
@@ -129,7 +130,7 @@ export namespace MessageEnvelope {
};
}
export class SyncRequest extends jspb.Message {
export declare class SyncRequest extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
@@ -178,7 +179,7 @@ export namespace SyncRequest {
};
}
export class SyncResponse extends jspb.Message {
export declare class SyncResponse extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 33 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