Compare commits

...

60 Commits

Author SHA1 Message Date
Zach Gollwitzer
224f21354a Bump to v0.6.0 2025-07-24 17:34:28 -04:00
Zach Gollwitzer
3fb379d140 Sync family icon button 2025-07-24 17:34:00 -04:00
Zach Gollwitzer
d90d35d97b Hosted version notice 2025-07-24 14:28:54 -04:00
Zach Gollwitzer
5baf258a32 Fix transactions tool call for chat 2025-07-24 14:09:30 -04:00
Zach Gollwitzer
bacab94a1b Fix import reverts 2025-07-24 11:41:42 -04:00
Zach Gollwitzer
7698ec03b9 Fix rule toggles 2025-07-24 11:30:40 -04:00
Zach Gollwitzer
0329a5f211 Data exports (#2517)
* Import / export UI

* Data exports

* Lint fixes, brakeman update
2025-07-24 10:50:05 -04:00
Zach Gollwitzer
b7c56e2fb7 Test fixes 2025-07-23 20:00:32 -04:00
Zach Gollwitzer
764164cf57 [claudesquad] update from 'transaction-page-filter-tweaks' on 23 Jul 25 18:27 EDT (#2513) 2025-07-23 18:37:31 -04:00
Zach Gollwitzer
ef49268278 [claudesquad] update from 'totals-rounding-and-sum' on 23 Jul 25 18:30 EDT (#2514) 2025-07-23 18:37:05 -04:00
Zach Gollwitzer
527a6128b6 Fix budget navigation to allow selecting previous months
- Allow going back 2 years minimum even without entries
- Update oldest_valid_budget_date to use min of entry date or 2 years ago
- Add comprehensive tests for budget date validation
- Fixes issue where users couldn't select prior budget months
2025-07-23 18:26:04 -04:00
Zach Gollwitzer
32ec57146e Fix form submission triggers (#2512) 2025-07-23 18:21:37 -04:00
Zach Gollwitzer
f7f6ebb091 Use new balance components in activity feed (#2511)
* Balance reconcilations with new components

* Fix materializer and test assumptions

* Fix investment valuation calculations and recon display

* Lint fixes

* Balance series uses new component fields
2025-07-23 18:15:14 -04:00
Juliano Julio Costa
3f92fe0f6f Relax API rate limits for self-hosted deployments (#2465)
- Introduced NoopApiRateLimiter to effectively disable API rate limiting for self-hosted mode.
- Updated ApiRateLimiter to delegate to NoopApiRateLimiter when running self-hosted.
- Increased Rack::Attack throttle limits significantly for self-hosted deployments.
- Added tests for NoopApiRateLimiter to ensure correct behavior.
- This allows self-hosted users to make more API requests without restriction, while keeping stricter limits for SaaS deployments.
2025-07-23 10:10:11 -04:00
Zach Gollwitzer
da2045dbd8 Additional cache columns on balances for activity view breakdowns (#2505)
* Initial schema iteration

* Add new balance components

* Add existing data migrator to backfill components

* Update calculator test assertions for new balance components

* Update flow assertions for forward calculator

* Update reverse calculator flows assumptions

* Forward calculator tests passing

* Get all calculator tests passing

* Assert flows factor
2025-07-23 10:06:25 -04:00
Akshay Birajdar
347c0a7906 feat: Only show active accounts for transaction form (#2484) 2025-07-22 06:21:00 -04:00
Zach Gollwitzer
321a343df4 Fix title for activity feed 2025-07-19 13:22:56 -04:00
Zach Gollwitzer
e8eb32d2ae Start and end balance breakdown in activity view (#2466)
* Initial data objects

* Remove trend calculator

* Fill in balance reconciliation for entry group

* Initial tooltip component

* Balance trends in activity view

* Lint fixes

* trade partial alignment fix

* Tweaks to balance calculation to acknowledge holdings value better

* More lint fixes

* Bump brakeman dep

* Test fixes

* Remove unused class
2025-07-18 17:56:25 -04:00
Zach Gollwitzer
ab6fdbbb68 Component namespacing (#2463)
* [claudesquad] update from 'component-namespacing' on 18 Jul 25 07:23 EDT

* [claudesquad] update from 'component-namespacing' on 18 Jul 25 07:30 EDT

* Update stimulus controller references to use namespace

* Fix remaining tests
2025-07-18 08:30:00 -04:00
Zach Gollwitzer
d5b147f2cd Add indexes to core models (#2464)
* [claudesquad] update from 'add-indexes-to-core-models' on 18 Jul 25 08:03 EDT

* [claudesquad] update from 'add-indexes-to-core-models' on 18 Jul 25 08:09 EDT
2025-07-18 08:19:44 -04:00
Zach Gollwitzer
8c97c9d31a Consolidate and simplify account pages (#2462)
* Remove ScrollFocusable

* Consolidate and simplify account pages

* Lint fixes

* Fix tab param initialization

* Remove stale files

* Remove stale route, make accountable routes clearer
2025-07-18 05:52:18 -04:00
Zach Gollwitzer
3eea5a9891 Add auto-update strategies for current balance on manual accounts (#2460)
* Add auto-update strategies for current balance on manual accounts

* Remove deprecated BalanceUpdater, replace with new methods
2025-07-17 06:49:56 -04:00
Zach Gollwitzer
52333e3fa6 Add reconciliation manager (#2459)
* Add reconciliation manager

* Fix notes editing
2025-07-16 11:31:47 -04:00
Zach Gollwitzer
89cc64418e Add confirmation dialog for balance reconciliation creates and updates (#2457) 2025-07-15 18:58:40 -04:00
Zach Gollwitzer
c1d98fe73b Start and end balance anchors for historical account balances (#2455)
* Add kind field to valuation

* Fix schema conflict

* Add kind to valuation

* Scaffold opening balance manager

* Opening balance manager implementation

* Update account import to use opening balance manager + tests

* Update account to use opening balance manager

* Fix test assertions, usage of current balance manager

* Lint fixes

* Add Opening Balance manager, add tests to forward calculator

* Add credit card to "all cash" designation

* Simplify valuation model

* Add current balance manager with tests

* Add current balance logic to reverse calculator and plaid sync

* Tweaks to initial calc logic

* Ledger testing helper, tweak assertions for reverse calculator

* Update test assertions

* Extract balance transformer, simplify calculators

* Algo simplifications

* Final tweaks to calculators

* Cleanup

* Fix error, propagate sync errors up to parent

* Update migration script, valuation naming
2025-07-15 11:42:41 -04:00
Zach Gollwitzer
9110ab27d2 Centralize entry naming (#2454)
* Centralize entry naming

* Lint fixes, code style
2025-07-10 18:40:38 -04:00
Zach Gollwitzer
afbfb474c2 Remove rate limit for api test cases 2025-07-10 16:04:36 -04:00
Zach Gollwitzer
fe8aebe920 Don't raise on invalid demo data 2025-07-10 16:01:47 -04:00
Zach Gollwitzer
188126d402 Fix activity view "new" button styles 2025-07-10 15:30:22 -04:00
Ezra Adeyinka
1a2d973f4b chore: fix armenian dram incorrect symbol (#2451) 2025-07-10 12:39:25 -04:00
Josh Pigford
a91441bcc8 Remove old unique index on device_id in mobile_devices migration to allow multiple users to share the same device. 2025-07-09 09:22:51 -05:00
Josh Pigford
8d0c1c5a56 Update database schema: increment version to 2025_07_02_173231, modify virtual column definition for classification, and add new indexes for entries and exchange rates. 2025-07-09 09:20:56 -05:00
dependabot[bot]
e848db2aa1 Bump sidekiq from 8.0.4 to 8.0.5 (#2441)
---
updated-dependencies:
- dependency-name: sidekiq
  dependency-version: 8.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:39:36 -04:00
dependabot[bot]
e7043328e4 Bump pagy from 9.3.4 to 9.3.5 (#2444)
---
updated-dependencies:
- dependency-name: pagy
  dependency-version: 9.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:39:19 -04:00
dependabot[bot]
d77c683d59 Bump faraday from 2.13.1 to 2.13.2 (#2442)
---
updated-dependencies:
- dependency-name: faraday
  dependency-version: 2.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:28:24 -04:00
dependabot[bot]
aaf24e1309 Bump stripe from 15.2.1 to 15.3.0 (#2445)
---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 15.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:25:31 -04:00
dependabot[bot]
f9b131a5db Bump faker from 3.5.1 to 3.5.2 (#2448)
---
updated-dependencies:
- dependency-name: faker
  dependency-version: 3.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:22:10 -04:00
dependabot[bot]
a63d36d10c Bump sentry-rails from 5.25.0 to 5.26.0 (#2447)
---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 5.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:22:01 -04:00
Zach Gollwitzer
662f2c04ce Multi-step account forms + clearer balance editing (#2427)
* Initial multi-step property form

* Improve form structure, add optional tooltip help icons to form fields

* Add basic inline alert component

* Clean up and improve property form lifecycle

* Implement Account status concept

* Lint fixes

* Remove whitespace

* Balance editing, scope updates for account

* Passing tests

* Fix brakeman warning

* Remove stale columns

* data constraint tweaks

* Redundant property
2025-07-03 09:33:07 -04:00
Eran Avidor
ba7e8d3893 Fix/design system violations (#2422)
* fix: replace hardcoded bg-white with bg-container in notification notice

* fix: replace hardcoded text-white with fg-inverse in notification CTA

* fix: replace hardcoded text-white with fg-inverse in text tooltip

* fix: replace hardcoded bg-gray-900 text-white with bg-inverse fg-inverse in invitations form

* fix: replace hardcoded bg-gray-800 text-white with bg-inverse fg-inverse in AI consent form

* fix: replace hardcoded text-white with fg-inverse in changelog page

* fix: replace hardcoded text-white and border-gray-500 with fg-inverse and border-secondary in investment tooltip

* fix: replace hardcoded text-white with fg-inverse in holdings missing price tooltip

* fix: replace hardcoded text-white and bg-gray-400 with fg-inverse and bg-surface-inset in settings profiles

* fix: replace hardcoded bg-orange-500 text-white with bg-yellow-600 fg-inverse in settings hosting danger zone

---------

Co-authored-by: Eran Avidor <eavidor@Eran-Avidor-MBP.lan>
2025-07-01 13:53:36 -04:00
Zach Gollwitzer
65329b333d Fix settings labels
Fixes #2424
2025-06-30 10:22:37 -04:00
dependabot[bot]
0974783a6b Bump selenium-webdriver from 4.33.0 to 4.34.0 (#2425)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.33.0 to 4.34.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.33.0...selenium-4.34.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-version: 4.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 09:29:20 -04:00
dependabot[bot]
48f792c20e Bump jwt from 2.10.1 to 2.10.2 (#2426)
Bumps [jwt](https://github.com/jwt/ruby-jwt) from 2.10.1 to 2.10.2.
- [Release notes](https://github.com/jwt/ruby-jwt/releases)
- [Changelog](https://github.com/jwt/ruby-jwt/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jwt/ruby-jwt/compare/v2.10.1...v2.10.2)

---
updated-dependencies:
- dependency-name: jwt
  dependency-version: 2.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 09:29:00 -04:00
Zach Gollwitzer
869462a9a5 Dynamic y-axis baseline value for chart scales 2025-06-27 12:57:23 -04:00
Zach Gollwitzer
e4a82d85e8 Properly handle Plaid investment account transfers (#2420) 2025-06-27 10:50:45 -04:00
Zach Gollwitzer
18148acd69 Fix chart scale issues (#2418) 2025-06-26 18:59:11 -04:00
Zach Gollwitzer
8db95623cf Handle holding quantity generation for reverse syncs correctly when not all holdings are generated for current day (#2417)
* Handle reverse calculator starting portfolio generation correctly

* Fix current_holdings to handle different dates and hide zero quantities

- Use DISTINCT ON to get most recent holding per security instead of assuming same date
- Filter out zero quantity holdings from UI display
- Maintain cash display regardless of zero balance
- Use single efficient query with proper Rails syntax

* Continue to process holdings even if one is not resolvable

* Lint fixes
2025-06-26 16:57:17 -04:00
Zach Gollwitzer
e60b5df442 Handle bad API data for trade quantity signage (#2416) 2025-06-26 09:54:25 -04:00
Zach Gollwitzer
f3ab4a27ee Fix credit card balance history (#2414) 2025-06-25 17:03:53 -04:00
Zach Gollwitzer
4b50acff2b Replace sync spinners with pulse animation (#2413)
* Replace sync spinners with pulse animation

* Remove dev code
2025-06-25 16:51:30 -04:00
Joseph Ho
637d630388 transfer: Support transfers of different currencies between accounts. (#2243)
Fixes part of #1852.

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-06-25 16:34:18 -04:00
Zach Gollwitzer
72a0f87a9c Fix race condition in sync status monitor (#2412)
Move family timestamp update to after_commit callback to ensure
database visibility before cache invalidation
2025-06-25 15:23:15 -04:00
Kenrick Tandrian
cea49d5038 fix(models): use self.id (#2410) 2025-06-24 11:15:28 -04:00
Josh Pigford
c0617f74cd Fix linting issues in migration file 2025-06-23 11:31:57 -05:00
Josh Pigford
653decbc0b Fix outdated timezone references
Updates outdated timezone identifiers in the database to their current
equivalents. This resolves ArgumentError exceptions when users have
outdated timezones like "Europe/Kiev" stored in their preferences.

Timezone mappings:
- Europe/Kiev → Europe/Kyiv
- Asia/Calcutta → Asia/Kolkata
- Asia/Katmandu → Asia/Kathmandu
- Asia/Rangoon → Asia/Yangon
- Asia/Saigon → Asia/Ho_Chi_Minh
- Pacific/Ponape → Pacific/Pohnpei
- Pacific/Truk → Pacific/Chuuk

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-23 11:25:03 -05:00
dependabot[bot]
1cfa6cfca8 Bump lookbook from 2.3.9 to 2.3.11 (#2405)
Bumps [lookbook](https://github.com/lookbook-hq/lookbook) from 2.3.9 to 2.3.11.
- [Release notes](https://github.com/lookbook-hq/lookbook/releases)
- [Commits](https://github.com/lookbook-hq/lookbook/compare/v2.3.9...v2.3.11)

---
updated-dependencies:
- dependency-name: lookbook
  dependency-version: 2.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:21:33 -04:00
dependabot[bot]
e809335a47 Bump faraday-retry from 2.3.1 to 2.3.2 (#2406)
Bumps [faraday-retry](https://github.com/lostisland/faraday-retry) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/lostisland/faraday-retry/releases)
- [Changelog](https://github.com/lostisland/faraday-retry/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday-retry/compare/v2.3.1...v2.3.2)

---
updated-dependencies:
- dependency-name: faraday-retry
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:21:25 -04:00
dependabot[bot]
956008acbf Bump debug from 1.10.0 to 1.11.0 (#2407)
Bumps [debug](https://github.com/ruby/debug) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/ruby/debug/releases)
- [Commits](https://github.com/ruby/debug/compare/v1.10.0...v1.11.0)

---
updated-dependencies:
- dependency-name: debug
  dependency-version: 1.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:21:14 -04:00
dependabot[bot]
8b56262573 Bump faraday-multipart from 1.1.0 to 1.1.1 (#2408)
Bumps [faraday-multipart](https://github.com/lostisland/faraday-multipart) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/lostisland/faraday-multipart/releases)
- [Changelog](https://github.com/lostisland/faraday-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday-multipart/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: faraday-multipart
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:21:07 -04:00
dependabot[bot]
615912040c Bump ostruct from 0.6.1 to 0.6.2 (#2409)
Bumps [ostruct](https://github.com/ruby/ostruct) from 0.6.1 to 0.6.2.
- [Release notes](https://github.com/ruby/ostruct/releases)
- [Commits](https://github.com/ruby/ostruct/compare/v0.6.1...v0.6.2)

---
updated-dependencies:
- dependency-name: ostruct
  dependency-version: 0.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:20:58 -04:00
371 changed files with 8249 additions and 1734 deletions

View File

@@ -26,7 +26,7 @@ gem "view_component"
# https://github.com/lookbook-hq/lookbook/issues/712
# TODO: Remove max version constraint when fixed
gem "lookbook", "2.3.9"
gem "lookbook", "2.3.11"
gem "hotwire_combobox"
@@ -72,6 +72,7 @@ gem "plaid"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"
gem "activerecord-import"
gem "rubyzip", "~> 2.3"
# State machines
gem "aasm"

View File

@@ -122,7 +122,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.0.2)
brakeman (7.1.0)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -151,7 +151,7 @@ GEM
addressable
csv (3.3.5)
date (3.4.1)
debug (1.10.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
derailed_benchmarks (2.2.1)
@@ -192,17 +192,17 @@ GEM
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
faker (3.5.1)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday (2.13.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday-retry (2.3.2)
faraday (~> 2.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -273,7 +273,7 @@ GEM
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
jwt (2.10.1)
jwt (2.10.2)
base64
language_server-protocol (3.17.0.5)
launchy (3.1.1)
@@ -301,7 +301,7 @@ GEM
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lookbook (2.3.9)
lookbook (2.3.11)
activemodel
css_parser
htmlbeautifier (~> 1.3)
@@ -364,8 +364,8 @@ GEM
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
ostruct (0.6.1)
pagy (9.3.4)
ostruct (0.6.2)
pagy (9.3.5)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -448,13 +448,13 @@ GEM
ffi (~> 1.0)
rbs (3.9.4)
logger
rdoc (6.14.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (5.4.0)
redis-client (>= 0.22.0)
redis-client (0.24.0)
redis-client (0.25.0)
connection_pool
regexp_parser (2.10.0)
reline (0.6.1)
@@ -516,22 +516,22 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.1)
selenium-webdriver (4.33.0)
selenium-webdriver (4.34.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.25.0)
sentry-rails (5.26.0)
railties (>= 5.0)
sentry-ruby (~> 5.25.0)
sentry-ruby (5.25.0)
sentry-ruby (~> 5.26.0)
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.25.0)
sentry-ruby (~> 5.25.0)
sentry-sidekiq (5.26.0)
sentry-ruby (~> 5.26.0)
sidekiq (>= 3.0)
sidekiq (8.0.4)
sidekiq (8.0.5)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@@ -556,7 +556,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stripe (15.2.1)
stripe (15.3.0)
tailwindcss-rails (4.2.3)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
@@ -651,7 +651,7 @@ DEPENDENCIES
jwt
letter_opener
logtail-rails
lookbook (= 2.3.9)
lookbook (= 2.3.11)
lucide-rails!
mocha
octokit
@@ -672,6 +672,7 @@ DEPENDENCIES
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai
rubyzip (~> 2.3)
selenium-webdriver
sentry-rails
sentry-ruby

View File

@@ -334,6 +334,19 @@
}
}
/* New form field structure components */
.form-field__header {
@apply flex items-center justify-between gap-2;
}
.form-field__body {
@apply flex flex-col gap-1;
}
.form-field__actions {
@apply flex items-center gap-1;
}
.form-field__label {
@apply block text-xs text-secondary peer-disabled:text-subdued;
}
@@ -347,10 +360,6 @@
@apply transition-opacity duration-300;
@apply placeholder:text-subdued;
&select {
@apply pr-8;
}
@variant theme-dark {
&::-webkit-calendar-picker-indicator {
filter: invert(1);
@@ -358,6 +367,14 @@
}
}
}
select.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
}
.form-field__radio {
@apply text-primary;
@@ -425,7 +442,5 @@
@variant theme-dark {
fill: var(--color-white);
}
}
}
}

View File

@@ -0,0 +1,7 @@
<div class="<%= container_classes %>">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
<div class="flex-1 text-sm">
<%= message %>
</div>
</div>

View File

@@ -0,0 +1,52 @@
class DS::Alert < DesignSystemComponent
def initialize(message:, variant: :info)
@message = message
@variant = variant
end
private
attr_reader :message, :variant
def container_classes
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
variant_classes = case variant
when :info
"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800"
when :success
"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800"
when :warning
"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800"
when :error, :destructive
"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800"
end
"#{base_classes} #{variant_classes}"
end
def icon_name
case variant
when :info
"info"
when :success
"check-circle"
when :warning
"alert-triangle"
when :error, :destructive
"x-circle"
end
end
def icon_color
case variant
when :success
"success"
when :warning
"warning"
when :error, :destructive
"destructive"
else
"blue-600"
end
end
end

View File

@@ -1,6 +1,6 @@
<%= container do %>
<% if icon && (icon_position != :right) %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %>
<% end %>
<% unless icon_only? %>

View File

@@ -2,7 +2,7 @@
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
# options available.
class ButtonComponent < ButtonishComponent
class DS::Button < DS::Buttonish
attr_reader :confirm
def initialize(confirm: nil, **opts)

View File

@@ -1,11 +1,11 @@
class ButtonishComponent < ViewComponent::Base
class DS::Buttonish < DesignSystemComponent
VARIANTS = {
primary: {
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
icon_classes: "fg-inverse"
},
secondary: {
container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
icon_classes: "fg-primary"
},
destructive: {
@@ -71,7 +71,7 @@ class ButtonishComponent < ViewComponent::Base
end
def call
raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly."
raise NotImplementedError, "Buttonish is an abstract class and cannot be instantiated directly."
end
def container_classes(override_classes = nil)

View File

@@ -1,7 +1,7 @@
<%= wrapper_element do %>
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
<%= tag.div class: dialog_outer_classes do %>
<%= tag.div class: dialog_inner_classes, data: { dialog_target: "content" } do %>
<%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %>
<div class="grow overflow-y-auto py-4 space-y-4 flex flex-col">
<% if header? %>
<%= header %>

View File

@@ -1,9 +1,9 @@
class DialogComponent < ViewComponent::Base
class DS::Dialog < DesignSystemComponent
renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon
close_icon = render DS::Button.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "DS--dialog#close" }) unless hide_close_icon
safe_join([ title, close_icon ].compact)
end
@@ -19,16 +19,16 @@ class DialogComponent < ViewComponent::Base
renders_many :actions, ->(cancel_action: false, **button_opts) do
merged_opts = if cancel_action
button_opts.merge(type: "button", data: { action: "modal#close" })
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
else
button_opts
end
render ButtonComponent.new(**merged_opts)
render DS::Button.new(**merged_opts)
end
renders_many :sections, ->(title:, **disclosure_opts, &block) do
render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
block.call
end
end
@@ -99,11 +99,11 @@ class DialogComponent < ViewComponent::Base
merged_opts = opts.dup
data = merged_opts.delete(:data) || {}
data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ")
data[:dialog_auto_open_value] = auto_open
data[:dialog_reload_on_close_value] = reload_on_close
data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ")
data[:hotkey] = "esc:dialog#close"
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
data[:DS__dialog_auto_open_value] = auto_open
data[:DS__dialog_reload_on_close_value] = reload_on_close
data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
data[:hotkey] = "esc:DS--dialog#close"
merged_opts[:data] = data
merged_opts

View File

@@ -0,0 +1,27 @@
<details class="group" <%= "open" if open %>>
<%= tag.summary class: class_names(
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
) do %>
<% if summary_content? %>
<%= summary_content %>
<% else %>
<div class="flex items-center gap-3">
<% if align == :left %>
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<% end %>
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
<%= title %>
<% end %>
</div>
<% if align == :right %>
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
<% end %>
<% end %>
<% end %>
<div class="mt-2">
<%= content %>
</div>
</details>

View File

@@ -1,9 +1,9 @@
class DisclosureComponent < ViewComponent::Base
class DS::Disclosure < DesignSystemComponent
renders_one :summary_content
attr_reader :title, :align, :open, :opts
def initialize(title:, align: "right", open: false, **opts)
def initialize(title: nil, align: "right", open: false, **opts)
@title = title
@align = align.to_sym
@open = open

View File

@@ -1,4 +1,4 @@
class FilledIconComponent < ViewComponent::Base
class DS::FilledIcon < DesignSystemComponent
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
VARIANTS = %i[default text surface container inverse].freeze

View File

@@ -1,7 +1,6 @@
<%= link_to href, **merged_opts do %>
<% if icon && (icon_position != "right") %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %>
<% unless icon_only? %>
@@ -10,6 +9,5 @@
<% if icon && icon_position == "right" %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %>
<% end %>

View File

@@ -1,6 +1,6 @@
# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
# options available.
class LinkComponent < ButtonishComponent
class DS::Link < DS::Buttonish
attr_reader :frame
VARIANTS = VARIANTS.reverse_merge(

View File

@@ -1,17 +1,17 @@
<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %>
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>
<% if variant == :icon %>
<%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
<% elsif variant == :button %>
<%= button %>
<% elsif variant == :avatar %>
<button data-menu-target="button">
<button data-DS--menu-target="button">
<div class="w-9 h-9 cursor-pointer">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div>
</button>
<% end %>
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= header %>

View File

@@ -1,15 +1,15 @@
# frozen_string_literal: true
class MenuComponent < ViewComponent::Base
class DS::Menu < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.merge(data: { menu_target: "button" })
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
if block
content_tag(:button, **options_with_target, &block)
else
ButtonComponent.new(**options_with_target)
DS::Button.new(**options_with_target)
end
end
@@ -19,7 +19,7 @@ class MenuComponent < ViewComponent::Base
renders_one :custom_content
renders_many :items, MenuItemComponent
renders_many :items, DS::MenuItem
VARIANTS = %i[icon button avatar].freeze

View File

@@ -1,4 +1,4 @@
class MenuItemComponent < ViewComponent::Base
class DS::MenuItem < DesignSystemComponent
VARIANTS = %i[link button divider].freeze
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts

View File

@@ -1,4 +1,4 @@
class TabComponent < ViewComponent::Base
class DS::Tab < DesignSystemComponent
attr_reader :id, :label
def initialize(id:, label:)

View File

@@ -0,0 +1,18 @@
<%= tag.div data: {
controller: "DS--tabs",
testid: testid,
DS__tabs_session_key_value: session_key,
DS__tabs_url_param_key_value: url_param_key,
DS__tabs_nav_btn_active_class: active_btn_classes,
DS__tabs_nav_btn_inactive_class: inactive_btn_classes
} do %>
<% if unstyled? %>
<%= content %>
<% else %>
<%= nav %>
<% panels.each do |panel| %>
<%= panel %>
<% end %>
<% end %>
<% end %>

View File

@@ -1,6 +1,6 @@
class TabsComponent < ViewComponent::Base
class DS::Tabs < DesignSystemComponent
renders_one :nav, ->(classes: nil) do
Tabs::NavComponent.new(
DS::Tabs::Nav.new(
active_tab: active_tab,
active_btn_classes: active_btn_classes,
inactive_btn_classes: inactive_btn_classes,
@@ -13,7 +13,7 @@ class TabsComponent < ViewComponent::Base
content_tag(
:div,
class: ("hidden" unless tab_id == active_tab),
data: { id: tab_id, tabs_target: "panel" },
data: { id: tab_id, DS__tabs_target: "panel" },
&block
)
end

View File

@@ -1,4 +1,4 @@
class Tabs::NavComponent < ViewComponent::Base
class DS::Tabs::Nav < DesignSystemComponent
erb_template <<~ERB
<%= tag.nav class: classes do %>
<% btns.each do |btn| %>
@@ -12,7 +12,7 @@ class Tabs::NavComponent < ViewComponent::Base
:button, label, id: id,
type: "button",
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
data: { id: id, action: "DS--tabs#show", DS__tabs_target: "navBtn" },
&block
)
end

View File

@@ -1,4 +1,4 @@
class Tabs::PanelComponent < ViewComponent::Base
class DS::Tabs::Panel < DesignSystemComponent
attr_reader :tab_id
def initialize(tab_id:)

View File

@@ -1,4 +1,4 @@
class ToggleComponent < ViewComponent::Base
class DS::Toggle < DesignSystemComponent
attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts
def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)

View File

@@ -0,0 +1,9 @@
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
<%= helpers.icon icon_name, size: size, color: color %>
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
<div class="fg-inverse font-normal max-w-[200px]">
<%= tooltip_content %>
</div>
</div>
</span>

View File

@@ -0,0 +1,17 @@
class DS::Tooltip < ApplicationComponent
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
@text = text
@placement = placement
@offset = offset
@cross_axis = cross_axis
@icon_name = icon
@size = size
@color = color
end
def tooltip_content
content? ? content : @text
end
end

View File

@@ -0,0 +1,87 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["tooltip"];
static values = {
placement: { type: String, default: "top" },
offset: { type: Number, default: 10 },
crossAxis: { type: Number, default: 0 },
};
connect() {
this._cleanup = null;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
}
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
}
addEventListeners() {
this.element.addEventListener("mouseenter", this.show);
this.element.addEventListener("mouseleave", this.hide);
}
removeEventListeners() {
this.element.removeEventListener("mouseenter", this.show);
this.element.removeEventListener("mouseleave", this.hide);
}
show = () => {
this.tooltipTarget.classList.remove("hidden");
this.startAutoUpdate();
this.update();
};
hide = () => {
this.tooltipTarget.classList.add("hidden");
this.stopAutoUpdate();
};
startAutoUpdate() {
if (!this._cleanup) {
const reference = this.element.querySelector("[data-icon]");
this._cleanup = autoUpdate(
reference || this.element,
this.tooltipTarget,
this.boundUpdate
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
const reference = this.element.querySelector("[data-icon]");
computePosition(reference || this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({
mainAxis: this.offsetValue,
crossAxis: this.crossAxisValue,
}),
flip(),
shift({ padding: 5 }),
],
}).then(({ x, y }) => {
Object.assign(this.tooltipTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
}

View File

@@ -0,0 +1,42 @@
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
<details class="group">
<summary>
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
<div class="flex pl-0.5 items-center gap-4">
<%= check_box_tag "#{date}_entries_selection",
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
id: "selection_entry_#{date}",
data: { action: "bulk-select#toggleGroupSelection" } %>
<p class="uppercase space-x-1.5">
<%= tag.span I18n.l(date, format: :long) %>
<span>&middot;</span>
<%= tag.span entries.size %>
</p>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
</div>
</div>
</summary>
<div class="p-4">
<% if balance %>
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
<% else %>
<p class="text-sm text-secondary">No balance data available for this date</p>
<% end %>
</div>
</details>
<div class="bg-container shadow-border-xs rounded-lg">
<% entries.each do |entry| %>
<%= render entry, view_ctx: "account" %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,31 @@
class UI::Account::ActivityDate < ApplicationComponent
attr_reader :account, :data
delegate :date, :entries, :balance, :transfers, to: :data
def initialize(account:, data:)
@account = account
@data = data
end
def id
dom_id(account, "entries_#{date}")
end
def broadcast_channel
account
end
def end_balance_money
balance&.end_balance_money || Money.new(0, account.currency)
end
def broadcast_refresh!
Turbo::StreamsChannel.broadcast_replace_to(
broadcast_channel,
target: id,
renderable: self,
layout: false
)
end
end

View File

@@ -0,0 +1,94 @@
<%= turbo_frame_tag dom_id(account, "entries") do %>
<div class="bg-container p-5 shadow-border-xs rounded-xl">
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
<%= tag.h2 "Activity", class: "font-medium text-lg" %>
<% if account.manual? %>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
<% menu.with_item(
variant: "link",
text: "New balance",
icon: "circle-dollar-sign",
href: new_valuation_path(account_id: account.id),
data: { turbo_frame: :modal }) %>
<% unless account.crypto? %>
<% menu.with_item(
variant: "link",
text: "New transaction",
icon: "credit-card",
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
data: { turbo_frame: :modal }) %>
<% end %>
<% end %>
<% end %>
</div>
<div>
<%= form_with url: account_path(account),
id: "entries-search",
scope: :q,
method: :get,
data: { controller: "auto-submit-form" } do |form| %>
<div class="flex gap-2 mb-4">
<div class="grow">
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
<%= helpers.icon("search") %>
<%= hidden_field_tag :account_id, account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",
value: search,
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
"data-auto-submit-form-target": "auto" %>
</div>
</div>
</div>
<% end %>
</div>
<% if activity_dates.empty? %>
<p class="text-secondary text-sm p-4">No entries yet</p>
<% else %>
<%= tag.div id: dom_id(account, "entries_bulk_select"),
data: {
controller: "bulk-select",
bulk_select_singular_label_value: "entry",
bulk_select_plural_label_value: "entries"
} do %>
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
<%= render "entries/selection_bar" %>
</div>
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
<div class="pl-0.5 col-span-8 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "checkbox checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<p>Date</p>
</div>
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
</div>
<div>
<div class="space-y-4">
<% activity_dates.each do |activity_date_data| %>
<%= render UI::Account::ActivityDate.new(
account: account,
data: activity_date_data
) %>
<% end %>
</div>
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
<%= render "shared/pagination", pagy: pagy %>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,35 @@
class UI::Account::ActivityFeed < ApplicationComponent
attr_reader :feed_data, :pagy, :search
def initialize(feed_data:, pagy:, search: nil)
@feed_data = feed_data
@pagy = pagy
@search = search
end
def id
dom_id(account, :activity_feed)
end
def broadcast_channel
account
end
def broadcast_refresh!
Turbo::StreamsChannel.broadcast_replace_to(
broadcast_channel,
target: id,
renderable: self,
layout: false
)
end
def activity_dates
feed_data.entries_by_date
end
private
def account
feed_data.account
end
end

View File

@@ -0,0 +1,22 @@
<div class="space-y-3">
<% reconciliation_items.each_with_index do |item, index| %>
<% if item[:style] == :subtotal %>
<hr class="border border-primary">
<% end %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
<%= item[:label] %>
<%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed <%= item[:style] == :final ? "border-primary" : "border-secondary" %>">
<dd class="<%= item[:style] == :start || item[:style] == :final ? "font-bold" : item[:style] == :subtotal ? "font-medium" : "" %>">
<%= item[:value].format %>
</dd>
</dl>
<% if item[:style] == :adjustment %>
<hr class="border border-primary">
<% end %>
<% end %>
</div>

View File

@@ -0,0 +1,155 @@
class UI::Account::BalanceReconciliation < ApplicationComponent
attr_reader :balance, :account
def initialize(balance:, account:)
@balance = balance
@account = account
end
def reconciliation_items
case account.accountable_type
when "Depository", "OtherAsset", "OtherLiability"
default_items
when "CreditCard"
credit_card_items
when "Investment"
investment_items
when "Loan"
loan_items
when "Property", "Vehicle"
asset_items
when "Crypto"
crypto_items
else
default_items
end
end
private
def default_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start },
{ label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow }
]
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final }
items
end
def credit_card_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start },
{ label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow },
{ label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow }
]
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final }
items
end
def investment_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start }
]
# Change in brokerage cash (includes deposits, withdrawals, and cash from trades)
items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow }
# Change in holdings from trading activity
items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow }
# Market price changes
items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow }
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final }
items
end
def loan_items
items = [
{ label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start },
{ label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow }
]
if has_adjustments?
items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal }
items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final }
items
end
def asset_items # Property/Vehicle
items = [
{ label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start },
{ label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow }
]
if has_adjustments?
items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment }
end
items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final }
items
end
def crypto_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start }
]
items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0
items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0
items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final }
items
end
def net_cash_flow
balance.cash_inflows_money - balance.cash_outflows_money
end
def net_non_cash_flow
balance.non_cash_inflows_money - balance.non_cash_outflows_money
end
def net_total_flow
net_cash_flow + net_non_cash_flow + balance.net_market_flows_money
end
def total_adjustments
balance.cash_adjustments_money + balance.non_cash_adjustments_money
end
def has_adjustments?
balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0
end
def end_balance_before_adjustments
balance.end_balance_money - total_adjustments
end
end

View File

@@ -1,32 +1,28 @@
<%# locals: (account:, tooltip: nil, chart_view: nil, **args) %>
<% period = @period || Period.last_30_days %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-border-xs rounded-xl space-y-2">
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
<div class="space-y-2 w-full">
<div class="flex items-center gap-1">
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
<%= tag.p title, class: "text-sm font-medium text-secondary" %>
<% if account.investment? %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>
<% end %>
</div>
<div class="flex flex-row gap-2 items-baseline">
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% if account.currency != Current.family.currency %>
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
<%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate" %>
<% if converted_balance_money %>
<%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %>
<% end %>
</div>
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
<%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
<div class="flex items-center gap-2">
<% if chart_view.present? %>
<% if account.investment? %>
<%= form.select :chart_view,
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
{ selected: chart_view },
{ selected: view },
class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
data: { "auto-submit-form-target": "auto" } %>
<% end %>
@@ -40,7 +36,23 @@
<% end %>
</div>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %>
<%= render "accounts/chart_loader" %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: period.comparison_label } %>
</div>
<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm">No data available</p>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,72 @@
class UI::Account::Chart < ApplicationComponent
attr_reader :account
def initialize(account:, period: nil, view: nil)
@account = account
@period = period
@view = view
end
def period
@period ||= Period.last_30_days
end
def holdings_value_money
account.balance_money - account.cash_balance_money
end
def view_balance_money
case view
when "balance"
account.balance_money
when "holdings_balance"
holdings_value_money
when "cash_balance"
account.cash_balance_money
end
end
def title
case account.accountable_type
when "Investment", "Crypto"
case view
when "balance"
"Total account value"
when "holdings_balance"
"Holdings value"
when "cash_balance"
"Cash value"
end
when "Property", "Vehicle"
"Estimated #{account.accountable_type.humanize.downcase} value"
when "CreditCard", "OtherLiability"
"Debt balance"
when "Loan"
"Remaining principal balance"
else
"Balance"
end
end
def foreign_currency?
account.currency != account.family.currency
end
def converted_balance_money
return nil unless foreign_currency?
account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)
end
def view
@view ||= "balance"
end
def series
account.balance_series(period: period, view: view)
end
def trend
series.trend
end
end

View File

@@ -0,0 +1,29 @@
<%= turbo_stream_from account %>
<%= turbo_frame_tag id do %>
<%= tag.div class: "space-y-4 pb-32" do %>
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
<%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>
<div class="min-h-[800px]" data-testid="account-details">
<% if tabs.count > 1 %>
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
<% tabs.each do |tab| %>
<% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %>
<% end %>
<% end %>
<% tabs.each do |tab| %>
<% tabs_container.with_panel(tab_id: tab) do %>
<%= tab_content_for(tab) %>
<% end %>
<% end %>
<% end %>
<% else %>
<%= tab_content_for(tabs.first) %>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,59 @@
class UI::AccountPage < ApplicationComponent
attr_reader :account, :chart_view, :chart_period
renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
@account = account
@chart_view = chart_view
@chart_period = chart_period
@active_tab = active_tab
end
def id
dom_id(account, :container)
end
def broadcast_channel
account
end
def broadcast_refresh!
Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false)
end
def title
account.name
end
def subtitle
return nil unless account.property?
account.property.address
end
def active_tab
tabs.find { |tab| tab == @active_tab&.to_sym } || tabs.first
end
def tabs
case account.accountable_type
when "Investment"
[ :activity, :holdings ]
when "Property", "Vehicle", "Loan"
[ :activity, :overview ]
else
[ :activity ]
end
end
def tab_content_for(tab)
case tab
when :activity
activity_feed
when :holdings, :overview
# Accountable is responsible for implementing the partial in the correct folder
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
end
end
end

View File

@@ -0,0 +1,4 @@
class ApplicationComponent < ViewComponent::Base
# These don't work as expected with helpers.turbo_frame_tag, etc., so we include them here
include Turbo::FramesHelper, Turbo::StreamsHelper
end

View File

@@ -0,0 +1,2 @@
class DesignSystemComponent < ViewComponent::Base
end

View File

@@ -1,25 +0,0 @@
<details class="group" <%= "open" if open %>>
<%= tag.summary class: class_names(
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
) do %>
<div class="flex items-center gap-3">
<% if align == :left %>
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<% end %>
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
<%= title %>
<% end %>
</div>
<% if align == :right %>
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
<% elsif summary_content? %>
<%= summary_content %>
<% end %>
<% end %>
<div class="mt-2">
<%= content %>
</div>
</details>

View File

@@ -1,18 +0,0 @@
<%= tag.div data: {
controller: "tabs",
testid: testid,
tabs_session_key_value: session_key,
tabs_url_param_key_value: url_param_key,
tabs_nav_btn_active_class: active_btn_classes,
tabs_nav_btn_inactive_class: inactive_btn_classes
} do %>
<% if unstyled? %>
<%= content %>
<% else %>
<%= nav %>
<% panels.each do |panel| %>
<%= panel %>
<% end %>
<% end %>
<% end %>

View File

@@ -32,7 +32,7 @@ class AccountableSparklinesController < ApplicationController
end
def account_ids
family.accounts.active.where(accountable_type: accountable.name).pluck(:id)
family.accounts.visible.where(accountable_type: accountable.name).pluck(:id)
end
def cache_key

View File

@@ -1,5 +1,5 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync chart sparkline]
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
include Periodable
def index
@@ -9,6 +9,22 @@ class AccountsController < ApplicationController
render layout: "settings"
end
def sync_all
family.sync_later
redirect_to accounts_path, notice: "Syncing accounts..."
end
def show
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
end
def sync
unless @account.syncing?
@account.sync_later
@@ -17,11 +33,6 @@ class AccountsController < ApplicationController
redirect_to account_path(@account)
end
def chart
@chart_view = params[:chart_view] || "balance"
render layout: "application"
end
def sparkline
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
@@ -33,6 +44,24 @@ class AccountsController < ApplicationController
end
end
def toggle_active
if @account.active?
@account.disable!
elsif @account.disabled?
@account.enable!
end
redirect_to accounts_path
end
def destroy
if @account.linked?
redirect_to account_path(@account), alert: "Cannot delete a linked account"
else
@account.destroy_later
redirect_to accounts_path, notice: "Account scheduled for deletion"
end
end
private
def family
Current.family

View File

@@ -9,7 +9,7 @@ class Api::V1::AccountsController < Api::V1::BaseController
def index
# Test with Pagy pagination
family = current_resource_owner.family
accounts_query = family.accounts.active.alphabetically
accounts_query = family.accounts.visible.alphabetically
# Handle pagination with Pagy
@pagy, @accounts = pagy(

View File

@@ -98,7 +98,7 @@ class Api::V1::BaseController < ApplicationController
@current_user = @api_key.user
@api_key.update_last_used!
@authentication_method = :api_key
@rate_limiter = ApiRateLimiter.new(@api_key)
@rate_limiter = ApiRateLimiter.limit(@api_key)
setup_current_context_for_api
true
end

View File

@@ -10,7 +10,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
def index
family = current_resource_owner.family
transactions_query = family.transactions.active
transactions_query = family.transactions.visible
# Apply filters
transactions_query = apply_filters(transactions_query)

View File

@@ -2,9 +2,9 @@ module AccountableResource
extend ActiveSupport::Concern
included do
include ScrollFocusable, Periodable
include Periodable
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_account, only: [ :show, :edit, :update ]
before_action :set_link_options, only: :new
end
@@ -27,9 +27,7 @@ module AccountableResource
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological
set_focused_record(entries, params[:focused_record_id])
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
end
def edit
@@ -43,19 +41,27 @@ module AccountableResource
end
def update
@account.update_with_sync!(account_params.except(:return_to))
@account.lock_saved_attributes!
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
end
def destroy
if @account.linked?
redirect_to account_path(@account), alert: "Cannot delete a linked account"
else
@account.destroy_later
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
# Handle balance update if provided
if account_params[:balance].present?
result = @account.set_current_balance(account_params[:balance].to_d)
unless result.success?
@error_message = result.error_message
render :edit, status: :unprocessable_entity
return
end
@account.sync_later
end
# Update remaining account attributes
update_params = account_params.except(:return_to, :balance, :currency)
unless @account.update(update_params)
@error_message = @account.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
return
end
@account.lock_saved_attributes!
redirect_back_or_to account_path(@account), notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
end
private
@@ -74,7 +80,7 @@ module AccountableResource
def account_params
params.require(:account).permit(
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to,
:name, :balance, :subtype, :currency, :accountable_type, :return_to,
accountable_attributes: self.class.permitted_accountable_attributes
)
end

View File

@@ -1,21 +0,0 @@
module ScrollFocusable
extend ActiveSupport::Concern
def set_focused_record(record_scope, record_id, default_per_page: 10)
return unless record_id.present?
@focused_record = record_scope.find_by(id: record_id)
record_index = record_scope.pluck(:id).index(record_id)
return unless record_index
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
if params[:page]&.to_i != page_of_focused_record
(
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
)
end
end
end

View File

@@ -0,0 +1,47 @@
class FamilyExportsController < ApplicationController
include StreamExtensions
before_action :require_admin
before_action :set_export, only: [ :download ]
def new
# Modal view for initiating export
end
def create
@export = Current.family.family_exports.create!
FamilyDataExportJob.perform_later(@export)
respond_to do |format|
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
format.turbo_stream {
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
}
end
end
def index
@exports = Current.family.family_exports.ordered.limit(10)
render layout: false # For turbo frame
end
def download
if @export.downloadable?
redirect_to @export.export_file, allow_other_host: true
else
redirect_to settings_profile_path, alert: "Export not ready for download"
end
end
private
def set_export
@export = Current.family.family_exports.find(params[:id])
end
def require_admin
unless Current.user.admin?
redirect_to root_path, alert: "Access denied"
end
end
end

View File

@@ -5,7 +5,7 @@ class PagesController < ApplicationController
def dashboard
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo
@accounts = Current.family.accounts.visible.with_attached_logo
period_param = params[:cashflow_period]
@cashflow_period = if period_param.present?

View File

@@ -1,21 +1,99 @@
class PropertiesController < ApplicationController
include AccountableResource
include AccountableResource, StreamExtensions
permitted_accountable_attributes(
:id, :year_built, :area_unit, :area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
)
before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ]
def new
@account = Current.family.accounts.build(
currency: Current.family.currency,
accountable: Property.new(
address: Address.new
)
@account = Current.family.accounts.build(accountable: Property.new)
end
def create
@account = Current.family.accounts.create!(
property_params.merge(currency: Current.family.currency, balance: 0, status: "draft")
)
redirect_to balances_property_path(@account)
end
def update
if @account.update(property_params)
@success_message = "Property details updated successfully."
if @account.active?
render :edit
else
redirect_to balances_property_path(@account)
end
else
@error_message = "Unable to update property details."
render :edit, status: :unprocessable_entity
end
end
def edit
@account.accountable.address ||= Address.new
end
def balances
end
def update_balances
result = @account.set_current_balance(balance_params[:balance].to_d)
if result.success?
@success_message = "Balance updated successfully."
if @account.active?
render :balances
else
redirect_to address_property_path(@account)
end
else
@error_message = result.error_message
render :balances, status: :unprocessable_entity
end
end
def address
@property = @account.property
@property.address ||= Address.new
end
def update_address
if @account.property.update(address_params)
if @account.draft?
@account.activate!
respond_to do |format|
format.html { redirect_to account_path(@account) }
format.turbo_stream { stream_redirect_to account_path(@account) }
end
else
@success_message = "Address updated successfully."
render :address
end
else
@error_message = "Unable to update address. Please check the required fields."
render :address, status: :unprocessable_entity
end
end
private
def balance_params
params.require(:account).permit(:balance, :currency)
end
def address_params
params.require(:property)
.permit(address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ])
end
def property_params
params.require(:account)
.permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])
end
def set_property
@account = Current.family.accounts.find(params[:id])
@property = @account.property
end
end

View File

@@ -1,5 +1,5 @@
class TransactionsController < ApplicationController
include ScrollFocusable, EntryableResource
include EntryableResource
before_action :store_params!, only: :index
@@ -21,12 +21,7 @@ class TransactionsController < ApplicationController
:transfer_as_inflow, :transfer_as_outflow
)
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
# No performance penalty by default. Only runs queries if the record is set.
if params[:focused_record_id].present?
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
end
@pagy, @transactions = pagy(base_scope, limit: per_page)
end
def clear_filter
@@ -115,7 +110,7 @@ class TransactionsController < ApplicationController
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
end
def needs_rule_notification?(transaction)
@@ -159,10 +154,6 @@ class TransactionsController < ApplicationController
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
# Only add default start_date if params are blank AND filters weren't explicitly cleared
if cleaned_params.blank? && params[:filter_cleared].blank?
cleaned_params[:start_date] = 30.days.ago.to_date
end
cleaned_params
end

View File

@@ -2,7 +2,7 @@ class TransferMatchesController < ApplicationController
before_action :set_entry
def new
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
@accounts = Current.family.accounts.visible.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transaction.transfer_match_candidates
end

View File

@@ -1,30 +1,70 @@
class ValuationsController < ApplicationController
include EntryableResource
include EntryableResource, StreamExtensions
def confirm_create
@account = Current.family.accounts.find(params.dig(:entry, :account_id))
@entry = @account.entries.build(entry_params.merge(currency: @account.currency))
@reconciliation_dry_run = @entry.account.create_reconciliation(
balance: entry_params[:amount],
date: entry_params[:date],
dry_run: true
)
render :confirm_create
end
def confirm_update
@entry = Current.family.entries.find(params[:id])
@account = @entry.account
@entry.assign_attributes(entry_params.merge(currency: @account.currency))
@reconciliation_dry_run = @entry.account.update_reconciliation(
@entry,
balance: entry_params[:amount],
date: entry_params[:date],
dry_run: true
)
render :confirm_update
end
def create
account = Current.family.accounts.find(params.dig(:entry, :account_id))
@entry = account.entries.new(entry_params.merge(entryable: Valuation.new))
if @entry.save
@entry.sync_account_later
flash[:notice] = "Balance created"
result = account.create_reconciliation(
balance: entry_params[:amount],
date: entry_params[:date],
)
if result.success?
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
format.html { redirect_back_or_to account_path(account), notice: "Account updated" }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
end
else
@error_message = result.error_message
render :new, status: :unprocessable_entity
end
end
def update
if @entry.update(entry_params)
@entry.sync_account_later
# Notes updating is independent of reconciliation, just a simple CRUD operation
@entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?
if entry_params[:date].present? && entry_params[:amount].present?
result = @entry.account.update_reconciliation(
@entry,
balance: entry_params[:amount],
date: entry_params[:date],
)
end
if result.nil? || result.success?
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" }
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(
@@ -37,13 +77,13 @@ class ValuationsController < ApplicationController
end
end
else
@error_message = result.error_message
render :show, status: :unprocessable_entity
end
end
private
def entry_params
params.require(:entry)
.permit(:name, :date, :amount, :currency, :notes)
params.require(:entry).permit(:date, :amount, :notes)
end
end

View File

@@ -0,0 +1,59 @@
class BalanceComponentMigrator
def self.run
ActiveRecord::Base.transaction do
# Step 1: Update flows factor
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances SET
flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END
FROM accounts a
WHERE a.id = balances.account_id
SQL
# Step 2: Set start values using LOCF (Last Observation Carried Forward)
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances b1
SET
start_cash_balance = COALESCE(prev.cash_balance, 0),
start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)
FROM balances b1_inner
LEFT JOIN LATERAL (
SELECT
b2.cash_balance,
b2.balance
FROM balances b2
WHERE b2.account_id = b1_inner.account_id
AND b2.currency = b1_inner.currency
AND b2.date < b1_inner.date
ORDER BY b2.date DESC
LIMIT 1
) prev ON true
WHERE b1.id = b1_inner.id
SQL
# Step 3: Calculate net inflows
# A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and
# the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed
# amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data.
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances SET
cash_inflows = (cash_balance - start_cash_balance) * flows_factor,
cash_outflows = 0,
non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,
non_cash_outflows = 0,
net_market_flows = 0
SQL
# Verify data integrity
# All end_balance values should match the original balance
invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)
SELECT COUNT(*)
FROM balances b
WHERE ABS(b.balance - b.end_balance) > 0.0001
SQL
if invalid_count > 0
raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values"
end
end
end
end

View File

@@ -21,7 +21,7 @@ module ApplicationHelper
if custom
inline_svg_tag("#{key}.svg", class: icon_classes, **opts)
elsif as_button
render ButtonComponent.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
render DS::Button.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
else
lucide_icon(key, class: icon_classes, **opts)
end

View File

@@ -1,19 +1,19 @@
module SettingsHelper
SETTINGS_ORDER = [
{ name: I18n.t("settings.settings_nav.profile_label"), path: :settings_profile_path },
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: "Account", path: :settings_profile_path },
{ name: "Preferences", path: :settings_preferences_path },
{ name: "Security", path: :settings_security_path },
{ name: "Self hosting", path: :settings_hosting_path, condition: :self_hosted? },
{ name: "API Key", path: :settings_api_key_path },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
{ name: "Accounts", path: :accounts_path },
{ name: "Imports", path: :imports_path },
{ name: "Tags", path: :tags_path },
{ name: "Categories", path: :categories_path },
{ name: "Rules", path: :rules_path },
{ name: I18n.t("settings.settings_nav.merchants_label"), path: :family_merchants_path },
{ name: I18n.t("settings.settings_nav.whats_new_label"), path: :changelog_path },
{ name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
{ name: "Merchants", path: :family_merchants_path },
{ name: "What's new", path: :changelog_path },
{ name: "Feedback", path: :feedback_path }
]
def adjacent_setting(current_path, offset)

View File

@@ -1,42 +1,38 @@
class StyledFormBuilder < ActionView::Helpers::FormBuilder
# Fields that visually inherit from "text field"
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
# Wraps "text" inputs with custom structure + base styles
text_field_helpers.each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
merged_options = { class: "form-field__input" }.merge(options)
label = build_label(method, options)
field = super(method, merged_options)
form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
html_options = options.except(:label, :label_tooltip, :inline, :container_class)
build_styled_field(label, field, merged_options)
build_field(method, form_options, html_options) do |merged_options|
super(method, merged_options)
end
end
RUBY_EVAL
end
def radio_button(method, tag_value, options = {})
merged_options = { class: "form-field__radio" }.merge(options)
super(method, tag_value, merged_options)
end
def select(method, choices, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options)
field_options = normalize_options(options, html_options)
label = build_label(method, options.merge(required: merged_html_options[:required]))
field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
build_field(method, field_options, html_options) do |merged_html_options|
super(method, choices, options, merged_html_options)
end
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options)
field_options = normalize_options(options, html_options)
label = build_label(method, options.merge(required: merged_html_options[:required]))
field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
build_field(method, field_options, html_options) do |merged_html_options|
super(method, collection, value_method, text_method, options, merged_html_options)
end
end
def money_field(amount_method, options = {})
@@ -48,22 +44,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
}
end
# A custom styled "toggle" switch input. Underlying input is a `check_box` (uses same API)
def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
if object
id = "#{object.id}_#{object_name}_#{method}"
name = "#{object_name}[#{method}]"
checked = object.send(method)
else
id = "#{method}_toggle_id"
name = method
checked = options[:checked]
end
field_id = field_id(method)
field_name = field_name(method)
checked = object ? object.send(method) : options[:checked]
@template.render(
ToggleComponent.new(
id: id,
name: name,
DS::Toggle.new(
id: field_id,
name: field_name,
checked: checked,
disabled: options[:disabled],
checked_value: checked_value,
@@ -74,12 +63,11 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def submit(value = nil, options = {})
# Rails superclass logic to extract the submit text
value, options = nil, value if value.is_a?(Hash)
value ||= submit_default_value
@template.render(
ButtonComponent.new(
DS::Button.new(
text: value,
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
full_width: true
@@ -88,16 +76,39 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
private
def build_styled_field(label, field, options, remove_padding_right: false)
if options[:inline]
label + field
else
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
label + field
def build_field(method, options = {}, html_options = {}, &block)
if options[:inline] || options[:label] == false
return yield({ class: "form-field__input" }.merge(html_options))
end
label_element = build_label(method, options)
field_element = yield({ class: "form-field__input" }.merge(html_options))
container_classes = [ "form-field", options[:container_class] ].compact
@template.tag.div class: container_classes do
if options[:label_tooltip]
@template.tag.div(class: "form-field__header") do
label_element +
@template.tag.div(class: "form-field__actions") do
build_tooltip(options[:label_tooltip])
end
end +
@template.tag.div(class: "form-field__body") do
field_element
end
else
@template.tag.div(class: "form-field__body") do
label_element + field_element
end
end
end
end
def normalize_options(options, html_options)
options.merge(required: options[:required] || html_options[:required])
end
def build_label(method, options)
return "".html_safe unless options[:label]
@@ -113,4 +124,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
return label(method, class: "form-field__label") if label_text == true
label(method, label_text, class: "form-field__label")
end
def build_tooltip(tooltip_text)
return nil unless tooltip_text
@template.tag.div(data: { controller: "tooltip" }) do
@template.safe_join([
@template.icon("help-circle", size: "sm", color: "default", class: "cursor-help"),
@template.tag.div(tooltip_text, role: "tooltip", data: { tooltip_target: "tooltip" }, class: "tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white")
])
end
end
end

View File

@@ -10,16 +10,14 @@ export default class extends Controller {
connect() {
this.autoTargets.forEach((element) => {
const event =
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
const event = this.#getTriggerEvent(element);
element.addEventListener(event, this.handleInput);
});
}
disconnect() {
this.autoTargets.forEach((element) => {
const event =
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
const event = this.#getTriggerEvent(element);
element.removeEventListener(event, this.handleInput);
});
}
@@ -33,6 +31,50 @@ export default class extends Controller {
}, this.#debounceTimeout(target));
};
#getTriggerEvent(element) {
// Check if element has explicit trigger event set
if (element.dataset.autosubmitTriggerEvent) {
return element.dataset.autosubmitTriggerEvent;
}
// Check if form has explicit trigger event set
if (this.triggerEventValue !== "input") {
return this.triggerEventValue;
}
// Otherwise, choose trigger event based on element type
const type = element.type || element.tagName;
switch (type.toLowerCase()) {
case "text":
case "email":
case "password":
case "search":
case "tel":
case "url":
case "textarea":
return "blur";
case "number":
case "date":
case "datetime-local":
case "month":
case "time":
case "week":
case "color":
return "change";
case "checkbox":
case "radio":
case "select":
case "select-one":
case "select-multiple":
return "change";
case "range":
return "input";
default:
return "blur";
}
}
#debounceTimeout(element) {
if (element.dataset.autosubmitDebounceTimeout) {
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);

View File

@@ -1,21 +0,0 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="focus-record"
export default class extends Controller {
static values = {
id: String,
};
connect() {
const element = document.getElementById(this.idValue);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
// Remove the focused_record_id parameter from URL
const url = new URL(window.location);
url.searchParams.delete("focused_record_id");
window.history.replaceState({}, "", url);
}
}
}

View File

@@ -508,15 +508,57 @@ export default class extends Controller {
}
get _d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
const dataMin = d3.min(this._normalDataPoints, this._getDatumValue);
const dataMax = d3.max(this._normalDataPoints, this._getDatumValue);
const padding = (dataMax - dataMin) * reductionPercent;
// Handle edge case where all values are the same
if (dataMin === dataMax) {
const padding = dataMax === 0 ? 100 : Math.abs(dataMax) * 0.5;
return d3
.scaleLinear()
.rangeRound([this._d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding]);
}
const dataRange = dataMax - dataMin;
const avgValue = (dataMax + dataMin) / 2;
// Calculate relative change as a percentage
const relativeChange = avgValue !== 0 ? dataRange / Math.abs(avgValue) : 1;
// Dynamic baseline calculation
let yMin;
let yMax;
// For small relative changes (< 10%), use a tighter scale
if (relativeChange < 0.1 && dataMin > 0) {
// Start axis at a percentage below the minimum, not at 0
const baselinePadding = dataRange * 2; // Show 2x the data range below min
yMin = Math.max(0, dataMin - baselinePadding);
yMax = dataMax + dataRange * 0.5; // Add 50% padding above
} else {
// For larger changes or when data crosses zero, use more context
// Always include 0 when data is negative or close to 0
if (dataMin < 0 || (dataMin >= 0 && dataMin < avgValue * 0.1)) {
yMin = Math.min(0, dataMin * 1.1);
} else {
// Otherwise use dynamic baseline
yMin = dataMin - dataRange * 0.3;
}
yMax = dataMax + dataRange * 0.1;
}
// Adjust padding for labels if needed
if (this.useLabelsValue) {
const extraPadding = (yMax - yMin) * 0.1;
yMin -= extraPadding;
yMax += extraPadding;
}
return d3
.scaleLinear()
.rangeRound([this._d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding]);
.domain([yMin, yMax]);
}
_setupResizeObserver() {

View File

@@ -0,0 +1,22 @@
class FamilyDataExportJob < ApplicationJob
queue_as :default
def perform(family_export)
family_export.update!(status: :processing)
exporter = Family::DataExporter.new(family_export.family)
zip_file = exporter.generate_export
family_export.export_file.attach(
io: zip_file,
filename: family_export.filename,
content_type: "application/zip"
)
family_export.update!(status: :completed)
rescue => e
Rails.logger.error "Family export failed: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
family_export.update!(status: :failed)
end
end

View File

@@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Chartable, Linkable, Enrichable
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
validates :name, :balance, :currency, presence: true
@@ -18,7 +18,7 @@ class Account < ApplicationRecord
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true) }
scope :visible, -> { where(status: [ "draft", "active" ]) }
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
@@ -30,30 +30,42 @@ class Account < ApplicationRecord
accepts_nested_attributes_for :accountable, update_only: true
# Account state machine
aasm column: :status, timestamps: true do
state :active, initial: true
state :draft
state :disabled
state :pending_deletion
event :activate do
transitions from: [ :draft, :disabled ], to: :active
end
event :disable do
transitions from: [ :draft, :active ], to: :disabled
end
event :enable do
transitions from: :disabled, to: :active
end
event :mark_for_deletion do
transitions from: [ :draft, :active, :disabled ], to: :pending_deletion
end
end
class << self
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
transaction do
# Create 2 valuations for new accounts to establish a value history for users to see
account.entries.build(
name: "Current Balance",
date: Date.current,
amount: account.balance,
currency: account.currency,
entryable: Valuation.new
)
account.entries.build(
name: "Initial Balance",
date: 1.day.ago.to_date,
amount: initial_balance,
currency: account.currency,
entryable: Valuation.new
)
account.save!
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(balance: initial_balance || account.balance)
raise result.error if result.error
end
account.sync_later
@@ -77,57 +89,29 @@ class Account < ApplicationRecord
end
def destroy_later
update!(scheduled_for_deletion: true, is_active: false)
mark_for_deletion!
DestroyJob.perform_later(self)
end
# Override destroy to handle error recovery for accounts
def destroy
super
rescue => e
# If destruction fails, transition back to disabled state
# This provides a cleaner recovery path than the generic scheduled_for_deletion flag
disable! if may_disable?
raise e
end
def current_holdings
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)
should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance
transaction do
update!(attributes)
update_balance!(attributes[:balance]) if should_update_balance
update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance
end
sync_later
end
def update_balance!(balance)
valuation = entries.valuations.find_by(date: Date.current)
if valuation
valuation.update! amount: balance
else
entries.create! \
date: Date.current,
name: "Balance update",
amount: balance,
currency: currency,
entryable: Valuation.new
end
end
def update_inital_balance!(initial_balance)
valuation = first_valuation
if valuation
valuation.update! amount: initial_balance
else
entries.create! \
date: Date.current,
name: "Initial Balance",
amount: initial_balance,
currency: currency,
entryable: Valuation.new
end
holdings.where(currency: currency)
.where.not(qty: 0)
.where(
id: holdings.select("DISTINCT ON (security_id) id")
.where(currency: currency)
.order(:security_id, date: :desc)
)
.order(amount: :desc)
end
def start_date
@@ -157,4 +141,23 @@ class Account < ApplicationRecord
def long_subtype_label
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end
# The balance type determines which "component" of balance is being tracked.
# This is primarily used for balance related calculations and updates.
#
# "Cash" = "Liquid"
# "Non-cash" = "Illiquid"
# "Investment" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)
def balance_type
case accountable_type
when "Depository", "CreditCard"
:cash
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
:non_cash
when "Investment", "Crypto"
:investment
else
raise "Unknown account type: #{accountable_type}"
end
end
end

View File

@@ -0,0 +1,85 @@
# Data used to build the paginated feed of account "activity" (events like transfers, deposits, withdrawals, etc.)
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
# activity feed component in controllers and background jobs that refresh it.
class Account::ActivityFeedData
ActivityDateData = Data.define(:date, :entries, :balance, :transfers)
attr_reader :account, :entries
def initialize(account, entries)
@account = account
@entries = entries.to_a
end
def entries_by_date
@entries_by_date_objects ||= begin
grouped_entries.map do |date, date_entries|
ActivityDateData.new(
date: date,
entries: date_entries,
balance: balance_for_date(date),
transfers: transfers_for_date(date)
)
end
end
end
private
def balance_for_date(date)
balances_by_date[date]
end
def transfers_for_date(date)
transfers_by_date[date] || []
end
def grouped_entries
@grouped_entries ||= entries.group_by(&:date)
end
def balances_by_date
@balances_by_date ||= begin
return {} if entries.empty?
dates = grouped_entries.keys
account.balances
.where(date: dates, currency: account.currency)
.index_by(&:date)
end
end
def transfers_by_date
@transfers_by_date ||= begin
return {} if transaction_ids.empty?
transfers = Transfer
.where(inflow_transaction_id: transaction_ids)
.or(Transfer.where(outflow_transaction_id: transaction_ids))
.to_a
# Group transfers by the date of their transaction entries
result = Hash.new { |h, k| h[k] = [] }
entries.each do |entry|
next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)
transfers.each do |transfer|
if transfer.inflow_transaction_id == entry.entryable_id ||
transfer.outflow_transaction_id == entry.entryable_id
result[entry.date] << transfer
end
end
end
# Remove duplicates
result.transform_values(&:uniq)
end
end
def transaction_ids
@transaction_ids ||= entries
.select(&:transaction?)
.map(&:entryable_id)
.compact
end
end

View File

@@ -0,0 +1,56 @@
# All accounts are "anchored" with start/end valuation records, with transactions,
# trades, and reconciliations between them.
module Account::Anchorable
extend ActiveSupport::Concern
included do
include Monetizable
monetize :opening_balance
end
def set_opening_anchor_balance(**opts)
result = opening_balance_manager.set_opening_balance(**opts)
sync_later if result.success?
result
end
def opening_anchor_date
opening_balance_manager.opening_date
end
def opening_anchor_balance
opening_balance_manager.opening_balance
end
def has_opening_anchor?
opening_balance_manager.has_opening_anchor?
end
def set_current_balance(balance)
result = current_balance_manager.set_current_balance(balance)
sync_later if result.success?
result
end
def current_anchor_balance
current_balance_manager.current_balance
end
def current_anchor_date
current_balance_manager.current_date
end
def has_current_anchor?
current_balance_manager.has_current_anchor?
end
private
def opening_balance_manager
@opening_balance_manager ||= Account::OpeningBalanceManager.new(self)
end
def current_balance_manager
@current_balance_manager ||= Account::CurrentBalanceManager.new(self)
end
end

View File

@@ -0,0 +1,141 @@
class Account::CurrentBalanceManager
InvalidOperation = Class.new(StandardError)
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
def initialize(account)
@account = account
end
def has_current_anchor?
current_anchor_valuation.present?
end
# Our system should always make sure there is a current anchor, and that it is up to date.
# The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value.
def current_balance
if current_anchor_valuation
current_anchor_valuation.entry.amount
else
Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date."
account.balance
end
end
def current_date
if current_anchor_valuation
current_anchor_valuation.entry.date
else
Date.current
end
end
def set_current_balance(balance)
if account.linked?
result = set_current_balance_for_linked_account(balance)
else
result = set_current_balance_for_manual_account(balance)
end
# Update cache field so changes appear immediately to the user
account.update!(balance: balance)
result
rescue => e
Result.new(success?: false, changes_made?: false, error: e.message)
end
private
attr_reader :account
def opening_balance_manager
@opening_balance_manager ||= Account::OpeningBalanceManager.new(account)
end
def reconciliation_manager
@reconciliation_manager ||= Account::ReconciliationManager.new(account)
end
# Manual accounts do not manage the `current_anchor` valuation (otherwise, user would need to continually update it, which is bad UX)
# Instead, we use a combination of "auto-update strategies" to set the current balance according to the user's intent.
#
# The "auto-update strategies" are:
# 1. Value tracking - If the account has a reconciliation already, we assume they are tracking the account value primarily with reconciliations, so we append a new one
# 2. Transaction adjustment - If the account doesn't have recons, we assume user is tracking with transactions, so we adjust the opening balance with a delta until it
# gets us to the desired balance. This ensures we don't append unnecessary reconciliations to the account, which "reset" the value from that
# date forward (not user's intent).
#
# For more documentation on these auto-update strategies, see the test cases.
def set_current_balance_for_manual_account(balance)
# If we're dealing with a cash account that has no reconciliations, use "Transaction adjustment" strategy (update opening balance to "back in" to the desired current balance)
if account.balance_type == :cash && account.valuations.reconciliation.empty?
adjust_opening_balance_with_delta(new_balance: balance, old_balance: account.balance)
else
existing_reconciliation = account.entries.valuations.find_by(date: Date.current)
result = reconciliation_manager.reconcile_balance(balance: balance, date: Date.current, existing_valuation_entry: existing_reconciliation)
# Normalize to expected result format
Result.new(success?: result.success?, changes_made?: true, error: result.error_message)
end
end
def adjust_opening_balance_with_delta(new_balance:, old_balance:)
delta = new_balance - old_balance
result = opening_balance_manager.set_opening_balance(balance: account.opening_anchor_balance + delta)
# Normalize to expected result format
Result.new(success?: result.success?, changes_made?: true, error: result.error)
end
# Linked accounts manage "current balance" via the special `current_anchor` valuation.
# This is NOT a user-facing feature, and is primarily used in "processors" while syncing
# linked account data (e.g. via Plaid)
def set_current_balance_for_linked_account(balance)
if current_anchor_valuation
changes_made = update_current_anchor(balance)
Result.new(success?: true, changes_made?: changes_made, error: nil)
else
create_current_anchor(balance)
Result.new(success?: true, changes_made?: true, error: nil)
end
end
def current_anchor_valuation
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
end
def create_current_anchor(balance)
account.entries.create!(
date: Date.current,
name: Valuation.build_current_anchor_name(account.accountable_type),
amount: balance,
currency: account.currency,
entryable: Valuation.new(kind: "current_anchor")
)
end
def update_current_anchor(balance)
changes_made = false
ActiveRecord::Base.transaction do
# Update associated entry attributes
entry = current_anchor_valuation.entry
if entry.amount != balance
entry.amount = balance
changes_made = true
end
if entry.date != Date.current
entry.date = Date.current
changes_made = true
end
entry.save! if entry.changed?
end
changes_made
end
end

View File

@@ -15,4 +15,5 @@ module Account::Linkable
def unlinked?
!linked?
end
alias_method :manual?, :unlinked?
end

View File

@@ -0,0 +1,99 @@
class Account::OpeningBalanceManager
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
def initialize(account)
@account = account
end
def has_opening_anchor?
opening_anchor_valuation.present?
end
# Most accounts should have an opening anchor. If not, we derive the opening date from the oldest entry date
def opening_date
return opening_anchor_valuation.entry.date if opening_anchor_valuation.present?
[
account.entries.valuations.order(:date).first&.date,
account.entries.where.not(entryable_type: "Valuation").order(:date).first&.date&.prev_day
].compact.min || Date.current
end
def opening_balance
opening_anchor_valuation&.entry&.amount || 0
end
def set_opening_balance(balance:, date: nil)
resolved_date = date || default_date
# Validate date is before oldest entry
if date && oldest_entry_date && resolved_date >= oldest_entry_date
return Result.new(success?: false, changes_made?: false, error: "Opening balance date must be before the oldest entry date")
end
if opening_anchor_valuation.nil?
create_opening_anchor(
balance: balance,
date: resolved_date
)
Result.new(success?: true, changes_made?: true, error: nil)
else
changes_made = update_opening_anchor(balance: balance, date: date)
Result.new(success?: true, changes_made?: changes_made, error: nil)
end
end
private
attr_reader :account
def opening_anchor_valuation
@opening_anchor_valuation ||= account.valuations.opening_anchor.includes(:entry).first
end
def oldest_entry_date
@oldest_entry_date ||= account.entries.minimum(:date)
end
def default_date
if oldest_entry_date
[ oldest_entry_date - 1.day, 2.years.ago.to_date ].min
else
2.years.ago.to_date
end
end
def create_opening_anchor(balance:, date:)
account.entries.create!(
date: date,
name: Valuation.build_opening_anchor_name(account.accountable_type),
amount: balance,
currency: account.currency,
entryable: Valuation.new(
kind: "opening_anchor"
)
)
end
def update_opening_anchor(balance:, date: nil)
changes_made = false
ActiveRecord::Base.transaction do
# Update associated entry attributes
entry = opening_anchor_valuation.entry
if entry.amount != balance
entry.amount = balance
changes_made = true
end
if date.present? && entry.date != date
entry.date = date
changes_made = true
end
entry.save! if entry.changed?
end
changes_made
end
end

View File

@@ -0,0 +1,20 @@
module Account::Reconcileable
extend ActiveSupport::Concern
def create_reconciliation(balance:, date:, dry_run: false)
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
sync_later if result.success? && !dry_run
result
end
def update_reconciliation(existing_valuation_entry, balance:, date:, dry_run: false)
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run)
sync_later if result.success? && !dry_run
result
end
private
def reconciliation_manager
@reconciliation_manager ||= Account::ReconciliationManager.new(self)
end
end

View File

@@ -0,0 +1,89 @@
class Account::ReconciliationManager
attr_reader :account
def initialize(account)
@account = account
end
# Reconciles balance by creating a Valuation entry. If existing valuation is provided, it will be updated instead of creating a new one.
def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_valuation_entry: nil)
old_balance_components = old_balance_components(reconciliation_date: date, existing_valuation_entry: existing_valuation_entry)
prepared_valuation = prepare_reconciliation(balance, date, existing_valuation_entry)
unless dry_run
prepared_valuation.save!
end
ReconciliationResult.new(
success?: true,
old_cash_balance: old_balance_components[:cash_balance],
old_balance: old_balance_components[:balance],
new_cash_balance: derived_cash_balance(date: date, total_balance: prepared_valuation.amount),
new_balance: prepared_valuation.amount,
error_message: nil
)
rescue => e
ReconciliationResult.new(
success?: false,
error_message: e.message
)
end
private
# Returns before -> after OR error message
ReconciliationResult = Struct.new(
:success?,
:old_cash_balance,
:old_balance,
:new_cash_balance,
:new_balance,
:error_message,
keyword_init: true
)
def prepare_reconciliation(balance, date, existing_valuation)
valuation_record = existing_valuation ||
account.entries.valuations.find_by(date: date) || # In case of conflict, where existing valuation is not passed as arg, but one exists
account.entries.build(
name: Valuation.build_reconciliation_name(account.accountable_type),
entryable: Valuation.new(kind: "reconciliation")
)
valuation_record.assign_attributes(
date: date,
amount: balance,
currency: account.currency
)
valuation_record
end
def derived_cash_balance(date:, total_balance:)
balance_components_for_reconciliation_date = get_balance_components_for_date(date)
return nil unless balance_components_for_reconciliation_date[:balance] && balance_components_for_reconciliation_date[:cash_balance]
# We calculate the existing non-cash balance, which for investments would represents "holdings" for the date of reconciliation
# Since the user is setting "total balance", we have to subtract the existing non-cash balance from the total balance to get the new cash balance
existing_non_cash_balance = balance_components_for_reconciliation_date[:balance] - balance_components_for_reconciliation_date[:cash_balance]
total_balance - existing_non_cash_balance
end
def old_balance_components(reconciliation_date:, existing_valuation_entry: nil)
if existing_valuation_entry
get_balance_components_for_date(existing_valuation_entry.date)
else
get_balance_components_for_date(reconciliation_date)
end
end
def get_balance_components_for_date(date)
balance_record = account.balances.find_by(date: date, currency: account.currency)
{
cash_balance: balance_record&.end_cash_balance,
balance: balance_record&.end_balance
}
end
end

View File

@@ -1,4 +1,6 @@
class AccountImport < Import
OpeningBalanceError = Class.new(StandardError)
def import!
transaction do
rows.each do |row|
@@ -15,13 +17,13 @@ class AccountImport < Import
account.save!
account.entries.create!(
amount: row.amount,
currency: row.currency,
date: Date.current,
name: "Imported account value",
entryable: Valuation.new
)
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(balance: row.amount.to_d)
# Re-raise since we should never have an error here
if result.error
raise OpeningBalanceError, result.error
end
end
end
end

View File

@@ -56,7 +56,7 @@ class Assistant::Function
end
def family_account_names
@family_account_names ||= family.accounts.active.pluck(:name)
@family_account_names ||= family.accounts.visible.pluck(:name)
end
def family_category_names

View File

@@ -22,7 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
type: account.accountable_type,
start_date: account.start_date,
is_plaid_linked: account.plaid_account_id.present?,
is_active: account.is_active,
status: account.status,
historical_balances: historical_balances(account)
}
end

View File

@@ -44,7 +44,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
private
def historical_data(period, classification: nil)
scope = family.accounts.active
scope = family.accounts.visible
scope = scope.where(classification: classification) if classification.present?
if period.start_date == Date.current

View File

@@ -134,7 +134,8 @@ class Assistant::Function::GetTransactions < Assistant::Function
def call(params = {})
search_params = params.except("order", "page")
transactions_query = family.transactions.active.search(search_params)
search = Transaction::Search.new(family, filters: search_params)
transactions_query = search.transactions_scope
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
@@ -149,7 +150,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
limit: default_page_size
)
totals = family.income_statement.totals(transactions_scope: transactions_query)
totals = search.totals
normalized_transactions = paginated_transactions.map do |txn|
entry = txn.entry

View File

@@ -2,8 +2,30 @@ class Balance < ApplicationRecord
include Monetizable
belongs_to :account
validates :account, :date, :balance, presence: true
monetize :balance
validates :flows_factor, inclusion: { in: [ -1, 1 ] }
monetize :balance, :cash_balance,
:start_cash_balance, :start_non_cash_balance, :start_balance,
:cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows,
:cash_adjustments, :non_cash_adjustments,
:end_cash_balance, :end_non_cash_balance, :end_balance
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
def balance_trend
Trend.new(
current: end_balance_money,
previous: start_balance_money,
favorable_direction: favorable_direction
)
end
private
def favorable_direction
flows_factor == -1 ? "down" : "up"
end
end

View File

@@ -0,0 +1,140 @@
class Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate
raise NotImplementedError, "Subclasses must implement this method"
end
private
def sync_cache
@sync_cache ||= Balance::SyncCache.new(account)
end
def holdings_value_for_date(date)
@holdings_value_for_date ||= {}
@holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount)
end
def derive_cash_balance_on_date_from_total(total_balance:, date:)
if account.balance_type == :investment
total_balance - holdings_value_for_date(date)
elsif account.balance_type == :cash
total_balance
else
0
end
end
def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)
return 0 unless account.balance_type != :non_cash
end_cash - start_cash - net_cash_flows
end
def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)
return 0 unless account.balance_type == :non_cash
end_non_cash - start_non_cash - non_cash_flows
end
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
# And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50)
# That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell
def market_value_change_on_date(date, flows)
return 0 unless account.balance_type == :investment
start_of_day_holdings_value = holdings_value_for_date(date.prev_day)
end_of_day_holdings_value = holdings_value_for_date(date)
change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value
net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]
change_holdings_value - net_buy_sell_value
end
def flows_for_date(date)
entries = sync_cache.get_entries(date)
cash_inflows = 0
cash_outflows = 0
non_cash_inflows = 0
non_cash_outflows = 0
txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)
txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)
trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)
trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)
if account.balance_type == :non_cash && account.accountable_type == "Loan"
non_cash_inflows = txn_inflow_sum.abs
non_cash_outflows = txn_outflow_sum
elsif account.balance_type != :non_cash
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum
# Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings")
non_cash_outflows = trade_cash_inflow_sum.abs
non_cash_inflows = trade_cash_outflow_sum
end
{
cash_inflows: cash_inflows,
cash_outflows: cash_outflows,
non_cash_inflows: non_cash_inflows,
non_cash_outflows: non_cash_outflows
}
end
def derive_cash_balance(cash_balance, date)
entries = sync_cache.get_entries(date)
if account.balance_type == :non_cash
0
else
cash_balance + signed_entry_flows(entries)
end
end
def derive_non_cash_balance(non_cash_balance, date, direction: :forward)
entries = sync_cache.get_entries(date)
# Loans are a special case (loan payment reducing principal, which is non-cash)
if account.balance_type == :non_cash && account.accountable_type == "Loan"
non_cash_balance + signed_entry_flows(entries)
elsif account.balance_type == :investment
# For reverse calculations, we need the previous day's holdings
target_date = direction == :forward ? date : date.prev_day
holdings_value_for_date(target_date)
else
non_cash_balance
end
end
def signed_entry_flows(entries)
raise NotImplementedError, "Directional calculators must implement this method"
end
def build_balance(date:, **args)
Balance.new(
account_id: account.id,
currency: account.currency,
date: date,
balance: args[:balance],
cash_balance: args[:cash_balance],
start_cash_balance: args[:start_cash_balance] || 0,
start_non_cash_balance: args[:start_non_cash_balance] || 0,
cash_inflows: args[:cash_inflows] || 0,
cash_outflows: args[:cash_outflows] || 0,
non_cash_inflows: args[:non_cash_inflows] || 0,
non_cash_outflows: args[:non_cash_outflows] || 0,
cash_adjustments: args[:cash_adjustments] || 0,
non_cash_adjustments: args[:non_cash_adjustments] || 0,
net_market_flows: args[:net_market_flows] || 0,
flows_factor: account.classification == "asset" ? 1 : -1
)
end
end

View File

@@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder
end
def balance_series
build_series_for(:balance)
build_series_for(:end_balance)
rescue => e
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def cash_balance_series
build_series_for(:cash_balance)
build_series_for(:end_cash_balance)
rescue => e
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def holdings_balance_series
build_series_for(:holdings_balance)
build_series_for(:end_holdings_balance)
rescue => e
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
raise
@@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder
def build_series_for(column)
values = query_data.map do |datum|
# Map column names to their start equivalents
previous_column = case column
when :end_balance then :start_balance
when :end_cash_balance then :start_cash_balance
when :end_holdings_balance then :start_holdings_balance
end
Series::Value.new(
date: datum.date,
date_formatted: I18n.l(datum.date, format: :long),
value: Money.new(datum.send(column), currency),
trend: Trend.new(
current: Money.new(datum.send(column), currency),
previous: Money.new(datum.send("previous_#{column}"), currency),
previous: Money.new(datum.send(previous_column), currency),
favorable_direction: favorable_direction
)
)
@@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder
WITH dates AS (
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
UNION DISTINCT
SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date
), aggregated_balances AS (
SELECT
d.date,
-- Total balance (assets positive, liabilities negative)
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.balance, 0)
ELSE -COALESCE(last_bal.balance, 0)
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS balance,
-- Cash-only balance
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.cash_balance, 0)
ELSE -COALESCE(last_bal.cash_balance, 0)
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS cash_balance,
-- Holdings value (balance cash)
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0)
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS holdings_balance
FROM dates d
JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[])
-- Last observation carried forward (LOCF), use the most recent balance on or before the chart date
LEFT JOIN LATERAL (
SELECT b.balance, b.cash_balance
FROM balances b
WHERE b.account_id = accounts.id
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1
) last_bal ON TRUE
-- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date
LEFT JOIN LATERAL (
SELECT er.rate
FROM exchange_rates er
WHERE er.from_currency = accounts.currency
AND er.to_currency = :target_currency
AND er.date <= d.date
ORDER BY er.date DESC
LIMIT 1
) er ON TRUE
GROUP BY d.date
SELECT :end_date::date -- Ensure end date is included
)
SELECT
date,
balance,
cash_balance,
holdings_balance,
COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance,
COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance,
COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance
FROM aggregated_balances
ORDER BY date
d.date,
-- Use flows_factor: already handles asset (+1) vs liability (-1)
COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,
COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,
-- Holdings only for assets (flows_factor = 1)
COALESCE(SUM(
CASE WHEN last_bal.flows_factor = 1
THEN last_bal.end_non_cash_balance
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS end_holdings_balance,
-- Previous balances
COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance,
COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance,
COALESCE(SUM(
CASE WHEN last_bal.flows_factor = 1
THEN last_bal.start_non_cash_balance
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS start_holdings_balance
FROM dates d
CROSS JOIN accounts
LEFT JOIN LATERAL (
SELECT b.end_balance,
b.end_cash_balance,
b.end_non_cash_balance,
b.start_balance,
b.start_cash_balance,
b.start_non_cash_balance,
b.flows_factor
FROM balances b
WHERE b.account_id = accounts.id
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1
) last_bal ON TRUE
LEFT JOIN LATERAL (
SELECT er.rate
FROM exchange_rates er
WHERE er.from_currency = accounts.currency
AND er.to_currency = :target_currency
AND er.date <= d.date
ORDER BY er.date DESC
LIMIT 1
) er ON TRUE
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
GROUP BY d.date
ORDER BY d.date
SQL
end
end

View File

@@ -1,61 +1,85 @@
class Balance::ForwardCalculator
attr_reader :account
def initialize(account)
@account = account
end
class Balance::ForwardCalculator < Balance::BaseCalculator
def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do
calculate_balances
start_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: account.opening_anchor_date
)
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_valuation(date)
if valuation
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: valuation.amount,
date: date
)
end_non_cash_balance = valuation.amount - end_cash_balance
else
end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date)
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
end
flows = flows_for_date(date)
market_value_change = market_value_change_on_date(date, flows)
cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor)
non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor)
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
cash_adjustments: cash_adjustments,
non_cash_adjustments: non_cash_adjustments,
net_market_flows: market_value_change
)
# Set values for the next iteration
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
output_balance
end
end
end
private
def calculate_balances
current_cash_balance = 0
next_cash_balance = nil
@balances = []
account.start_date.upto(Date.current).each do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
next_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :forward)
end
@balances << build_balance(date, next_cash_balance, holdings_value)
current_cash_balance = next_cash_balance
end
@balances
def calc_start_date
account.opening_anchor_date
end
def sync_cache
@sync_cache ||= Balance::SyncCache.new(account)
def calc_end_date
[ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
end
def build_balance(date, cash_balance, holdings_value)
Balance.new(
account_id: account.id,
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
# Negative entries amount on an "asset" account means, "account value has increased"
# Negative entries amount on a "liability" account means, "account debt has decreased"
# Positive entries amount on an "asset" account means, "account value has decreased"
# Positive entries amount on a "liability" account means, "account debt has increased"
def signed_entry_flows(entries)
entry_flows = entries.sum(&:amount)
account.asset? ? -entry_flows : entry_flows
end
def calculate_next_balance(prior_balance, transactions, direction: :forward)
flows = transactions.sum(&:amount)
negated = direction == :forward ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
# Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
def derive_end_cash_balance(start_cash_balance:, date:)
derive_cash_balance(start_cash_balance, date)
end
# Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
end
def flows_factor
account.asset? ? 1 : -1
end
end

View File

@@ -28,9 +28,20 @@ class Balance::Materializer
end
def update_account_info
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
calculated_cash_balance = calculated_balance - calculated_holdings_value
# Query fresh balance from DB to get generated column values
current_balance = account.balances
.where(currency: account.currency)
.order(date: :desc)
.first
if current_balance
calculated_balance = current_balance.end_balance
calculated_cash_balance = current_balance.end_cash_balance
else
# Fallback if no balance exists
calculated_balance = 0
calculated_cash_balance = 0
end
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
@@ -48,14 +59,23 @@ class Balance::Materializer
current_time = Time.now
account.balances.upsert_all(
@balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.slice("date", "balance", "cash_balance", "currency",
"start_cash_balance", "start_non_cash_balance",
"cash_inflows", "cash_outflows",
"non_cash_inflows", "non_cash_outflows",
"net_market_flows",
"cash_adjustments", "non_cash_adjustments",
"flows_factor")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end
def purge_stale_balances
deleted_count = account.balances.delete_by("date < ?", account.start_date)
sorted_balances = @balances.sort_by(&:date)
oldest_calculated_balance_date = sorted_balances.first&.date
newest_calculated_balance_date = sorted_balances.last&.date
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end

View File

@@ -1,71 +1,82 @@
class Balance::ReverseCalculator
attr_reader :account
def initialize(account)
@account = account
end
class Balance::ReverseCalculator < Balance::BaseCalculator
def calculate
Rails.logger.tagged("Balance::ReverseCalculator") do
calculate_balances
# Since it's a reverse sync, we're starting with the "end of day" balance components and
# calculating backwards to derive the "start of day" balance components.
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.current_anchor_balance,
date: account.current_anchor_date
)
end_non_cash_balance = account.current_anchor_balance - end_cash_balance
# Calculates in reverse-chronological order (End of day -> Start of day)
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
flows = flows_for_date(date)
if use_opening_anchor_for_date?(date)
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: date
)
end_non_cash_balance = account.opening_anchor_balance - end_cash_balance
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
market_value_change = 0
else
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
market_value_change = market_value_change_on_date(date, flows)
end
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
net_market_flows: market_value_change
)
end_cash_balance = start_cash_balance
end_non_cash_balance = start_non_cash_balance
output_balance
end
end
end
private
def calculate_balances
current_cash_balance = account.cash_balance
previous_cash_balance = nil
@balances = []
Date.current.downto(account.start_date).map do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
previous_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
end
if valuation.present?
@balances << build_balance(date, previous_cash_balance, holdings_value)
else
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider
if date == Date.current
@balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance)
else
@balances << build_balance(date, current_cash_balance, holdings_value)
end
end
current_cash_balance = previous_cash_balance
end
@balances
# Negative entries amount on an "asset" account means, "account value has increased"
# Negative entries amount on a "liability" account means, "account debt has decreased"
# Positive entries amount on an "asset" account means, "account value has decreased"
# Positive entries amount on a "liability" account means, "account debt has increased"
def signed_entry_flows(entries)
entry_flows = entries.sum(&:amount)
account.asset? ? entry_flows : -entry_flows
end
def sync_cache
@sync_cache ||= Balance::SyncCache.new(account)
# Alias method, for algorithmic clarity
# Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
def derive_start_cash_balance(end_cash_balance:, date:)
derive_cash_balance(end_cash_balance, date)
end
def build_balance(date, cash_balance, holdings_value)
Balance.new(
account_id: account.id,
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
# Alias method, for algorithmic clarity
# Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
end
def calculate_next_balance(prior_balance, transactions, direction: :forward)
flows = transactions.sum(&:amount)
negated = direction == :forward ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
# explanation, see the test suite.
def use_opening_anchor_for_date?(date)
account.has_opening_anchor? && date == account.opening_anchor_date
end
end

View File

@@ -1,30 +0,0 @@
# The current system calculates a single, end-of-day balance every day for each account for simplicity.
# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances
# to show users how each entry affects their balances. This class calculates intraday balances by
# interpolating between end-of-day balances.
class Balance::TrendCalculator
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
def initialize(balances)
@balances = balances
end
def trend_for(date)
balance = @balances.find { |b| b.date == date }
prior_balance = @balances.find { |b| b.date == date - 1.day }
return BalanceTrend.new(trend: nil) unless balance.present?
BalanceTrend.new(
trend: Trend.new(
current: Money.new(balance.balance, balance.currency),
previous: Money.new(prior_balance.balance, balance.currency),
favorable_direction: balance.account.favorable_direction
),
cash: Money.new(balance.cash_balance, balance.currency),
)
end
private
attr_reader :balances
end

View File

@@ -23,8 +23,8 @@ class BalanceSheet::AccountTotals
delegate_missing_to :account
end
def active_accounts
@active_accounts ||= family.accounts.active.with_attached_logo
def visible_accounts
@visible_accounts ||= family.accounts.visible.with_attached_logo
end
def account_rows
@@ -46,7 +46,7 @@ class BalanceSheet::AccountTotals
def query
@query ||= Rails.cache.fetch(cache_key) do
active_accounts
visible_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
Date.current,

View File

@@ -6,7 +6,7 @@ class BalanceSheet::NetWorthSeriesBuilder
def net_worth_series(period: Period.last_30_days)
Rails.cache.fetch(cache_key(period)) do
builder = Balance::ChartSeriesBuilder.new(
account_ids: active_account_ids,
account_ids: visible_account_ids,
currency: family.currency,
period: period,
favorable_direction: "up"
@@ -19,8 +19,8 @@ class BalanceSheet::NetWorthSeriesBuilder
private
attr_reader :family
def active_account_ids
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id)
def visible_account_ids
@visible_account_ids ||= family.accounts.visible.with_attached_logo.pluck(:id)
end
def cache_key(period)

View File

@@ -17,7 +17,7 @@ class BalanceSheet::SyncStatusMonitor
def syncing_account_ids
Rails.cache.fetch(cache_key) do
Sync.visible
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id))
.where(syncable_type: "Account", syncable_id: family.accounts.visible.pluck(:id))
.pluck(:syncable_id)
.to_set
end

View File

@@ -49,7 +49,10 @@ class Budget < ApplicationRecord
private
def oldest_valid_budget_date(family)
@oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month
# Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier
two_years_ago = 2.years.ago.beginning_of_month
oldest_entry_date = family.oldest_entry_date.beginning_of_month
[ two_years_ago, oldest_entry_date ].min
end
end
@@ -88,7 +91,7 @@ class Budget < ApplicationRecord
end
def transactions
family.transactions.active.in_period(period)
family.transactions.visible.in_period(period)
end
def name

View File

@@ -72,6 +72,14 @@ module Accountable
self.class.display_name
end
def balance_display_name
"account value"
end
def opening_balance_display_name
"opening balance"
end
def icon
self.class.icon
end

View File

@@ -47,7 +47,7 @@ module Syncable
end
def sync_error
latest_sync&.error
latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first
end
def last_synced_at

View File

@@ -1174,42 +1174,42 @@ class Demo::Generator
# Property valuations (these accounts are valued, not transaction-driven)
@home.entries.create!(
entryable: Valuation.new,
entryable: Valuation.new(kind: "current_anchor"),
amount: 350_000,
name: "Current Market Value",
name: Valuation.build_current_anchor_name(@home.accountable_type),
currency: "USD",
date: Date.current
)
# Vehicle valuations (these depreciate over time)
@honda_accord.entries.create!(
entryable: Valuation.new,
entryable: Valuation.new(kind: "current_anchor"),
amount: 18_000,
name: "Current Market Value",
name: Valuation.build_current_anchor_name(@honda_accord.accountable_type),
currency: "USD",
date: Date.current
)
@tesla_model3.entries.create!(
entryable: Valuation.new,
entryable: Valuation.new(kind: "current_anchor"),
amount: 4_500,
name: "Current Market Value",
name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type),
currency: "USD",
date: Date.current
)
@jewelry.entries.create!(
entryable: Valuation.new,
entryable: Valuation.new(kind: "reconciliation"),
amount: 2000,
name: "Current Market Value",
name: Valuation.build_reconciliation_name(@jewelry.accountable_type),
currency: "USD",
date: 90.days.ago.to_date
)
@personal_loc.entries.create!(
entryable: Valuation.new,
entryable: Valuation.new(kind: "reconciliation"),
amount: 800,
name: "Owed",
name: Valuation.build_reconciliation_name(@personal_loc.accountable_type),
currency: "USD",
date: 120.days.ago.to_date
)

View File

@@ -14,8 +14,8 @@ class Entry < ApplicationRecord
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
scope :active, -> {
joins(:account).where(accounts: { is_active: true })
scope :visible, -> {
joins(:account).where(accounts: { status: [ "draft", "active" ] })
}
scope :chronological, -> {

View File

@@ -14,7 +14,7 @@ module Entryable
scope :with_entry, -> { joins(:entry) }
scope :active, -> { with_entry.merge(Entry.active) }
scope :visible, -> { with_entry.merge(Entry.visible) }
scope :in_period, ->(period) {
with_entry.where(entries: { date: period.start_date..period.end_date })

View File

@@ -18,6 +18,7 @@ class Family < ApplicationRecord
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :family_exports, dependent: :destroy
has_many :entries, through: :accounts
has_many :transactions, through: :accounts
@@ -100,7 +101,8 @@ class Family < ApplicationRecord
[
id,
key,
data_invalidation_key
data_invalidation_key,
accounts.maximum(:updated_at)
].compact.join("_")
end

View File

@@ -9,8 +9,6 @@ module Family::AutoTransferMatchable
JOIN entries outflow_candidates ON (
inflow_candidates.amount < 0 AND
outflow_candidates.amount > 0 AND
inflow_candidates.amount = -outflow_candidates.amount AND
inflow_candidates.currency = outflow_candidates.currency AND
inflow_candidates.account_id <> outflow_candidates.account_id AND
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
)
@@ -24,12 +22,26 @@ module Family::AutoTransferMatchable
rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)")
.joins("LEFT JOIN exchange_rates ON (
exchange_rates.date = outflow_candidates.date AND
exchange_rates.from_currency = outflow_candidates.currency AND
exchange_rates.to_currency = inflow_candidates.currency
)")
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id)
.where("inflow_accounts.is_active = true")
.where("outflow_accounts.is_active = true")
.where("inflow_accounts.status IN ('draft', 'active')")
.where("outflow_accounts.status IN ('draft', 'active')")
.where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'")
.where("
(
inflow_candidates.currency = outflow_candidates.currency AND
inflow_candidates.amount = -outflow_candidates.amount
) OR (
inflow_candidates.currency <> outflow_candidates.currency AND
ABS(inflow_candidates.amount / NULLIF(outflow_candidates.amount * exchange_rates.rate, 0)) BETWEEN 0.95 AND 1.05
)
")
.where(existing_transfers: { id: nil })
.order("date_diff ASC") # Closest matches first
end

View File

@@ -0,0 +1,238 @@
require "zip"
require "csv"
class Family::DataExporter
def initialize(family)
@family = family
end
def generate_export
# Create a StringIO to hold the zip data in memory
zip_data = Zip::OutputStream.write_buffer do |zipfile|
# Add accounts.csv
zipfile.put_next_entry("accounts.csv")
zipfile.write generate_accounts_csv
# Add transactions.csv
zipfile.put_next_entry("transactions.csv")
zipfile.write generate_transactions_csv
# Add trades.csv
zipfile.put_next_entry("trades.csv")
zipfile.write generate_trades_csv
# Add categories.csv
zipfile.put_next_entry("categories.csv")
zipfile.write generate_categories_csv
# Add all.ndjson
zipfile.put_next_entry("all.ndjson")
zipfile.write generate_ndjson
end
# Rewind and return the StringIO
zip_data.rewind
zip_data
end
private
def generate_accounts_csv
CSV.generate do |csv|
csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ]
# Only export accounts belonging to this family
@family.accounts.includes(:accountable).find_each do |account|
csv << [
account.id,
account.name,
account.accountable_type,
account.subtype,
account.balance.to_s,
account.currency,
account.created_at.iso8601
]
end
end
end
def generate_transactions_csv
CSV.generate do |csv|
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
# Only export transactions from accounts belonging to this family
@family.transactions
.includes(:category, :tags, entry: :account)
.find_each do |transaction|
csv << [
transaction.entry.date.iso8601,
transaction.entry.account.name,
transaction.entry.amount.to_s,
transaction.entry.name,
transaction.category&.name,
transaction.tags.pluck(:name).join(","),
transaction.entry.notes,
transaction.entry.currency
]
end
end
end
def generate_trades_csv
CSV.generate do |csv|
csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ]
# Only export trades from accounts belonging to this family
@family.trades
.includes(:security, entry: :account)
.find_each do |trade|
csv << [
trade.entry.date.iso8601,
trade.entry.account.name,
trade.security.ticker,
trade.qty.to_s,
trade.price.to_s,
trade.entry.amount.to_s,
trade.currency
]
end
end
end
def generate_categories_csv
CSV.generate do |csv|
csv << [ "name", "color", "parent_category", "classification" ]
# Only export categories belonging to this family
@family.categories.includes(:parent).find_each do |category|
csv << [
category.name,
category.color,
category.parent&.name,
category.classification
]
end
end
end
def generate_ndjson
lines = []
# Export accounts with full accountable data
@family.accounts.includes(:accountable).find_each do |account|
lines << {
type: "Account",
data: account.as_json(
include: {
accountable: {}
}
)
}.to_json
end
# Export categories
@family.categories.find_each do |category|
lines << {
type: "Category",
data: category.as_json
}.to_json
end
# Export tags
@family.tags.find_each do |tag|
lines << {
type: "Tag",
data: tag.as_json
}.to_json
end
# Export merchants (only family merchants)
@family.merchants.find_each do |merchant|
lines << {
type: "Merchant",
data: merchant.as_json
}.to_json
end
# Export transactions with full data
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
lines << {
type: "Transaction",
data: {
id: transaction.id,
entry_id: transaction.entry.id,
account_id: transaction.entry.account_id,
date: transaction.entry.date,
amount: transaction.entry.amount,
currency: transaction.entry.currency,
name: transaction.entry.name,
notes: transaction.entry.notes,
excluded: transaction.entry.excluded,
category_id: transaction.category_id,
merchant_id: transaction.merchant_id,
tag_ids: transaction.tag_ids,
kind: transaction.kind,
created_at: transaction.created_at,
updated_at: transaction.updated_at
}
}.to_json
end
# Export trades with full data
@family.trades.includes(:security, entry: :account).find_each do |trade|
lines << {
type: "Trade",
data: {
id: trade.id,
entry_id: trade.entry.id,
account_id: trade.entry.account_id,
security_id: trade.security_id,
ticker: trade.security.ticker,
date: trade.entry.date,
qty: trade.qty,
price: trade.price,
amount: trade.entry.amount,
currency: trade.currency,
created_at: trade.created_at,
updated_at: trade.updated_at
}
}.to_json
end
# Export valuations
@family.entries.valuations.includes(:account, :entryable).find_each do |entry|
lines << {
type: "Valuation",
data: {
id: entry.entryable.id,
entry_id: entry.id,
account_id: entry.account_id,
date: entry.date,
amount: entry.amount,
currency: entry.currency,
name: entry.name,
created_at: entry.created_at,
updated_at: entry.updated_at
}
}.to_json
end
# Export budgets
@family.budgets.find_each do |budget|
lines << {
type: "Budget",
data: budget.as_json
}.to_json
end
# Export budget categories
@family.budget_categories.includes(:budget, :category).find_each do |budget_category|
lines << {
type: "BudgetCategory",
data: budget_category.as_json
}.to_json
end
lines.join("\n")
end
end

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