Compare commits

...

68 Commits

Author SHA1 Message Date
Zach Gollwitzer
0691041d37 Update required Ruby version for development in README
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-13 12:50:26 -04:00
Chris Covington
b437bb20c4 Bump ruby from 3.3.1 to 3.3.4 (#1084) 2024-08-13 12:49:51 -04:00
Pedro Carmona
3c64f3ff3b Fix: i18n symbol typo (#1085) 2024-08-13 12:31:51 -04:00
dependabot[bot]
82d3b8bcaf Bump rails from 43530b4 to f6d62b5 (#1083)
Bumps [rails](https://github.com/rails/rails) from `43530b4` to `f6d62b5`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](43530b4ac9...f6d62b5f21)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:41:33 -04:00
Pedro Carmona
14c4b9e93c Refactor: Use native error i18n lookup (#1076) 2024-08-12 20:38:58 -04:00
dependabot[bot]
150fce41a8 Bump ruby-lsp-rails from 0.3.11 to 0.3.12 (#1081)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.11 to 0.3.12.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.11...v0.3.12)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  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>
2024-08-12 20:33:49 -04:00
dependabot[bot]
67f65d399e Bump bootsnap from 1.18.3 to 1.18.4 (#1079)
Bumps [bootsnap](https://github.com/Shopify/bootsnap) from 1.18.3 to 1.18.4.
- [Changelog](https://github.com/Shopify/bootsnap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Shopify/bootsnap/compare/v1.18.3...v1.18.4)

---
updated-dependencies:
- dependency-name: bootsnap
  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>
2024-08-12 20:33:30 -04:00
dependabot[bot]
72fe6d87f0 Bump tailwindcss-rails from 2.6.5 to 2.7.2 (#1078)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.5 to 2.7.2.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.5...v2.7.2)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  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>
2024-08-12 20:26:38 -04:00
Zach Gollwitzer
94be117a02 Deposit, Withdrawal, and Interest Transactions for Investment View (#1075)
* Trade and Transaction builders

* Consolidate logic

* Remove redundant fields from trade form

* Add deposit, withdrawal, and interest form controls
2024-08-09 20:11:27 -04:00
Zach Gollwitzer
f3c44464be Update version.rb
Bump to v0.1.0-alpha.14

Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-09 17:42:48 -04:00
Zach Gollwitzer
c0908f454a Temp fix for missing accountables on self hosted instances (#1071)
* Temp fix #1068

* Cleanup
2024-08-09 13:31:32 -04:00
Zach Gollwitzer
e05f03b314 Allow user to add buy and sell trade transactions for investment accounts (#1066)
* Consolidate modal form structure into partial + helper

* Scaffold out trade transaction form

* Normalize translations

* Add buy and sell trade form with tests

* Move entryable lists to dedicated controllers

* Delegate entry group contents rendering

* More cleanup

* Extract transaction and valuation update logic from entries controller

* Delegate edit and show actions to entryables

* Trade builder

* Update paths for transaction updates
2024-08-09 11:22:57 -04:00
Tony Vincent
6bca35fa22 Fix minitest assert_nil warning (#1070)
* Fix minitest assert_nil warning

* Remove empty line

* Fix my stupidity
2024-08-09 10:58:01 -04:00
Tony Vincent
6fa40e0fa2 Fetch exchange rates in bulk from synth (#1069)
* Fetch exchnage rates in bulk

* Handle paginated response

* Rename method and improve tests

* Change argument names

* Use standard date format
2024-08-09 10:57:33 -04:00
Tony Vincent
f315370512 Add stimulus tooltip controller (#1065)
* Add Tooltip Stimulus controller

* Add test for tooltip

* Remove comma

* Normalize translations

* Use floating-ui instead popper

* Use component classes

* Increase cross axis value

* Cleanup

* Update app/views/accounts/show.html.erb

Use correct tailwind class

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Tony Vincent <tonyvince7@gmail.com>

* Use default values for options

* Remove tooltip global variable

* Add arrow target

* Remove unused method

---------

Signed-off-by: Tony Vincent <tonyvince7@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-08-08 06:53:27 -04:00
Zach Gollwitzer
6e74414cb2 Add source headers to Synth calls (#1062) 2024-08-05 12:21:12 -04:00
dependabot[bot]
9ad04a82cb Bump rails from 5cb5cad to 43530b4 (#1059)
Bumps [rails](https://github.com/rails/rails) from `5cb5cad` to `43530b4`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](5cb5cad322...43530b4ac9)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:13:34 -04:00
dependabot[bot]
7c878697f4 Bump pagy from 9.0.3 to 9.0.5 (#1056)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.3 to 9.0.5.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.3...9.0.5)

---
updated-dependencies:
- dependency-name: pagy
  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>
2024-08-05 09:13:03 -04:00
dependabot[bot]
cdb134077d Bump good_job from 4.1.0 to 4.1.1 (#1053)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: good_job
  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>
2024-08-05 09:00:32 -04:00
dependabot[bot]
65aeab4681 Bump aws-sdk-s3 from 1.156.0 to 1.157.0 (#1054)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.156.0 to 1.157.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  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>
2024-08-05 08:59:30 -04:00
dependabot[bot]
e0d2b951d6 Bump erb_lint from 0.5.0 to 0.6.0 (#1057)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.5.0...v0.6.0)

---
updated-dependencies:
- dependency-name: erb_lint
  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>
2024-08-05 08:58:05 -04:00
dependabot[bot]
4eeca00121 Bump faraday from 2.10.0 to 2.10.1 (#1055)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.10.0 to 2.10.1.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.10.0...v2.10.1)

---
updated-dependencies:
- dependency-name: faraday
  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>
2024-08-05 08:57:19 -04:00
dependabot[bot]
07a7a6b1aa Bump tailwindcss-rails from 2.6.4 to 2.6.5 (#1058)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.4 to 2.6.5.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.4...v2.6.5)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  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>
2024-08-05 08:57:03 -04:00
Zach Gollwitzer
edda5cb35b Bump to v0.1.0-alpha.13
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-02 17:10:16 -04:00
Zach Gollwitzer
ea8309eedd Show cash + holdings value for investment account view (#1046)
* Handle missing tickers in security price syncs

* Show combined cash and holdings value on account page

* Improve partial locals
2024-08-02 17:09:25 -04:00
Zach Gollwitzer
453a54e5e6 Add security prices provider (Synth integration) (#1039)
* User tickers as primary lookup symbol instead of isin

* Add security price provider

* Fetch security prices in bulk to improve sync performance

* Fetch prices in bulk, better mocking for tests
2024-08-01 19:43:23 -04:00
Zach Gollwitzer
c70c8b6d86 Ensure transfer name is populated (#1042)
* Ensure transfer name is populated

* Transfer amount fallback
2024-08-01 12:10:30 -04:00
Tony Vincent
f2a2d2f7e4 Fix demo data reset (#1041)
* Fix demo data reset

* Only delete test user
2024-08-01 08:56:32 -04:00
Mikhail Wahib
0a21c92643 fix: long emails overflow in account menu dropdown (#1034) 2024-07-31 12:24:01 -04:00
dependabot[bot]
2c5f647f53 Bump rails from 1b89033 to 5cb5cad (#1035)
Bumps [rails](https://github.com/rails/rails) from `1b89033` to `5cb5cad`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](1b89033460...5cb5cad322)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:40:35 -04:00
dependabot[bot]
11f58537db Bump pagy from 9.0.2 to 9.0.3 (#1030)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pagy
  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>
2024-07-31 09:32:09 -04:00
dependabot[bot]
6231814e1e Bump mocha from 2.4.2 to 2.4.5 (#1029)
Bumps [mocha](https://github.com/freerange/mocha) from 2.4.2 to 2.4.5.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.4.2...v2.4.5)

---
updated-dependencies:
- dependency-name: mocha
  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>
2024-07-31 09:31:13 -04:00
dependabot[bot]
7645a9ec56 Bump image_processing from 1.12.2 to 1.13.0 (#1028)
Bumps [image_processing](https://github.com/janko/image_processing) from 1.12.2 to 1.13.0.
- [Changelog](https://github.com/janko/image_processing/blob/master/CHANGELOG.md)
- [Commits](https://github.com/janko/image_processing/compare/v1.12.2...v1.13.0)

---
updated-dependencies:
- dependency-name: image_processing
  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>
2024-07-31 09:31:06 -04:00
dependabot[bot]
08b59ad5fe Bump pg from 1.5.6 to 1.5.7 (#1027)
Bumps [pg](https://github.com/ged/ruby-pg) from 1.5.6 to 1.5.7.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/History.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.5.6...v1.5.7)

---
updated-dependencies:
- dependency-name: pg
  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>
2024-07-31 09:30:39 -04:00
dependabot[bot]
02adba5280 Bump tailwindcss-rails from 2.6.3 to 2.6.4 (#1031)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  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>
2024-07-31 09:30:31 -04:00
dependabot[bot]
1f5721a8b1 Bump sentry-rails from 5.18.1 to 5.18.2 (#1033)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.18.1 to 5.18.2.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.18.1...5.18.2)

---
updated-dependencies:
- dependency-name: sentry-rails
  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>
2024-07-31 09:30:21 -04:00
pranavbabu
7ba9830db5 Fix: Omit layout for turbo frames with custom sidebar layout (#1024)
* Define layout method

* Use with_sidebar method

---------

Co-authored-by: Pranav Babu <babu@maindeck.io>
2024-07-26 12:00:41 -04:00
Zach Gollwitzer
dfc7e1c30c Bump to v0.1.0-alpha.12
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-26 10:48:21 -04:00
Zach Gollwitzer
76dd5e57fb Set minimum supported date for account entries (#1023)
* Set minimum supported date for account entries

* Fix validation proc

* Fix date input in system tests
2024-07-26 10:47:27 -04:00
Zach Gollwitzer
701e17829d Fix currency formatting in pie chart visualization (#1022) 2024-07-26 10:36:29 -04:00
Zach Gollwitzer
7c2091b343 Basic Portfolio Views (#1000)
* Add holdings tab to account view

* Basic portfolio UI

* Cleanup

* Handle missing holding data

* Remove synced at (implemented in separate pr)

* translations

* Tweak post sync streams

* Remove stale methods from merge conflict
2024-07-25 16:46:04 -04:00
Zach Gollwitzer
ef4be7948a Implement auto family syncs on login (#1021) 2024-07-25 12:51:50 -04:00
Julius Mieliauskas
c8590d53ba Fix curency format (#1020)
* Fixed currency formatting

* Revert "Fixed currency formatting"

This reverts commit 8c7ff442b8.

* fix currency formating
2024-07-25 10:40:03 -04:00
Tony Vincent
f62c5e43c3 Fix form labels (#1004)
* Fix form labels

* Fix typo

* Change form builder

* Simplify label_html private method of StyledFormBuilder
2024-07-22 10:04:55 -04:00
dependabot[bot]
82568b4d8c Bump rails from 8035bec to 1b89033 (#1007)
Bumps [rails](https://github.com/rails/rails) from `8035bec` to `1b89033`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](8035bece70...1b89033460)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 10:00:39 -04:00
dependabot[bot]
9d006409c2 Bump turbo-rails from 2.0.5 to 2.0.6 (#1008)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.5 to 2.0.6.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.5...v2.0.6)

---
updated-dependencies:
- dependency-name: turbo-rails
  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>
2024-07-22 09:53:21 -04:00
dependabot[bot]
55a085f01f Bump ruby-lsp-rails from 0.3.10 to 0.3.11 (#1010)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.10 to 0.3.11.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.10...v0.3.11)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  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>
2024-07-22 09:53:01 -04:00
dependabot[bot]
23dcdf6e26 Bump selenium-webdriver from 4.22.0 to 4.23.0 (#1011)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.22.0 to 4.23.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.22.0...selenium-4.23.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  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>
2024-07-22 09:52:34 -04:00
dependabot[bot]
05e3e689b5 Bump good_job from 4.0.3 to 4.1.0 (#1012)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.0.3 to 4.1.0.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.0.3...v4.1.0)

---
updated-dependencies:
- dependency-name: good_job
  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>
2024-07-22 09:52:17 -04:00
dependabot[bot]
01f50dc54c Bump mocha from 2.4.0 to 2.4.2 (#1013)
Bumps [mocha](https://github.com/freerange/mocha) from 2.4.0 to 2.4.2.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.4.0...v2.4.2)

---
updated-dependencies:
- dependency-name: mocha
  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>
2024-07-22 09:51:43 -04:00
dependabot[bot]
5d213f2e6a Bump tailwindcss-rails from 2.6.1 to 2.6.3 (#1014)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.1 to 2.6.3.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.1...v2.6.3)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  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>
2024-07-22 09:51:31 -04:00
dependabot[bot]
952d847c15 Bump faker from 3.4.1 to 3.4.2 (#1015)
Bumps [faker](https://github.com/faker-ruby/faker) from 3.4.1 to 3.4.2.
- [Release notes](https://github.com/faker-ruby/faker/releases)
- [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faker-ruby/faker/compare/v3.4.1...v3.4.2)

---
updated-dependencies:
- dependency-name: faker
  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>
2024-07-22 09:51:12 -04:00
Tony Vincent
e7dc6b88ea Bump pagy with breaking changes fix (#1016) 2024-07-22 09:49:53 -04:00
Tony Vincent
75ded1c18f Set last_login_at only at login instead of every single action (#1017) 2024-07-22 09:37:03 -04:00
Zach Gollwitzer
c0e0c2bf62 Bump to v0.1.0-alpha.11
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-19 16:09:05 -04:00
Zach Gollwitzer
fa08f027c7 Sync notifications and troubleshooting guides (#998)
* Add help articles

* Broadcast sync messages as notifications

* Lint fixes

* more lint fixes

* Remove redundant code
2024-07-18 14:39:38 -04:00
Zach Gollwitzer
b200b71284 Add currency validation to account, update demo data generator (#996)
* Add currency validation to account, update demo data generator

* Fix tests
2024-07-17 14:18:12 -04:00
Zach Gollwitzer
ef0f910b9b Build sample portfolio deterministically (#993) 2024-07-17 08:57:28 -04:00
Zach Gollwitzer
e9f42c1a65 Add default currencies to forms based on preference (#994)
* Add default currencies to forms based on preference

* Remove dev debugging
2024-07-17 08:57:17 -04:00
Zach Gollwitzer
e51806b98b More composable forms (#989)
* Make forms more composable, opt-in to form builder

* Remove unused method

* Simpler money input controls

* Add in new form styling to imports

* Lint fixes

* Small tweak of multi select styles
2024-07-16 14:08:24 -04:00
Zach Gollwitzer
47523f64c2 Investment Portfolio Sync (#974)
* Add investment portfolio models

* Add portfolio to demo data

* Setup initial tests

* Rough sketch of sync logic

* Clean up trade sync logic

* Add trade validation

* Integrate trades into sync process
2024-07-16 09:26:49 -04:00
Tony Vincent
d0bc959bee Sanitize input for ilike in Account::Entry.search (#988) 2024-07-16 09:26:14 -04:00
Tony Vincent
cdbca5aff3 Allow CSV file upload in import flow (#986)
* Add .tool-versions to gitignore

* Add dropzone js for drag and drop file uploads

* UI for csv file uploads for import

* dropzone controller and use lucide_icon instead of svg

* Preview for file chosen

* File upload

* Remove dropzone

* Normalize I18n keys and fix lint issues

* Add system tests

* Cleanup

* Remove unwanted
2024-07-16 09:23:45 -04:00
dependabot[bot]
41f9e23f8c Bump rails from 8075866 to 8035bec (#982)
Bumps [rails](https://github.com/rails/rails) from `8075866` to `8035bec`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](8075866ae8...8035bece70)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-15 10:21:24 -04:00
dependabot[bot]
12123449b7 Bump good_job from 4.0.0 to 4.0.3 (#981)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.0.0 to 4.0.3.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.0.0...v4.0.3)

---
updated-dependencies:
- dependency-name: good_job
  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>
2024-07-15 10:07:12 -04:00
dependabot[bot]
a70c6666dc Bump ruby-lsp-rails from 0.3.8 to 0.3.10 (#983)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.8 to 0.3.10.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.8...v0.3.10)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  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>
2024-07-15 10:04:39 -04:00
dependabot[bot]
1bd5397701 Bump faraday from 2.9.2 to 2.10.0 (#984)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.9.2 to 2.10.0.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.9.2...v2.10.0)

---
updated-dependencies:
- dependency-name: faraday
  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>
2024-07-15 10:04:30 -04:00
Andrey Morskov
37d5c149ba Wrap account update in transaction (#985) 2024-07-15 10:03:35 -04:00
257 changed files with 4570 additions and 1556 deletions

View File

@@ -1,4 +1,4 @@
ARG RUBY_VERSION=3.3.1
ARG RUBY_VERSION=3.3.4
FROM ruby:${RUBY_VERSION}-slim-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \

17
.env.test.example Normal file
View File

@@ -0,0 +1,17 @@
# ================
# Data Providers
# ---------------------------------------------------------------------------------
# Uncomment and fill in live keys when you need to generate a VCR cassette fixture
# ================
# SYNTH_API_KEY=<add live key here>
# ================
# Miscellaneous
# ================
# Set to true if you want SimpleCov reports generated
COVERAGE=false
# Set to true to run test suite serially
DISABLE_PARALLELIZATION=false

4
.gitignore vendored
View File

@@ -11,6 +11,7 @@
/.env*
!/.env*.erb
!.env.example
!.env.test.example
# Ignore all logfiles and tempfiles.
/log/*
@@ -51,6 +52,9 @@
# Ignore .devcontainer files
compose-dev.yaml
# Ignore asdf ruby version file
.tool-versions
# Ignore GCP keyfile
gcp-storage-keyfile.json

View File

@@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
Layout/ElseAlignment:
Enabled: false
Layout/EndAlignment:
Enabled: false

View File

@@ -1 +1 @@
3.3.1
3.3.4

View File

@@ -44,6 +44,7 @@ gem "pagy"
gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "csv"
gem "redcarpet"
group :development, :test do
gem "debug", platforms: %i[ mri windows ]
@@ -51,10 +52,10 @@ group :development, :test do
gem "rubocop-rails-omakase", require: false
gem "i18n-tasks"
gem "erb_lint"
gem "dotenv-rails"
end
group :development do
gem "dotenv-rails"
gem "hotwire-livereload"
gem "letter_opener"
gem "ruby-lsp-rails"

View File

@@ -7,69 +7,69 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 8075866ae8dfee76e1c6099b9eea6dcb7df70803
revision: f6d62b5f214471f6af0fc0535fe70e4e13b19ed4
branch: 7-2-stable
specs:
actioncable (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
actioncable (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
actionmailbox (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
mail (>= 2.8.0)
actionmailer (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
actionview (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
actionmailer (7.2.0)
actionpack (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activesupport (= 7.2.0)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0.beta2)
actionview (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
actionpack (7.2.0)
actionview (= 7.2.0)
activesupport (= 7.2.0)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
actiontext (7.2.0)
actionpack (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
actionview (7.2.0)
activesupport (= 7.2.0)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
activejob (7.2.0)
activesupport (= 7.2.0)
globalid (>= 0.3.6)
activemodel (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
activerecord (7.2.0.beta2)
activemodel (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
activemodel (7.2.0)
activesupport (= 7.2.0)
activerecord (7.2.0)
activemodel (= 7.2.0)
activesupport (= 7.2.0)
timeout (>= 0.4.0)
activestorage (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
activestorage (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activesupport (= 7.2.0)
marcel (~> 1.0)
activesupport (7.2.0.beta2)
activesupport (7.2.0)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -78,24 +78,25 @@ GIT
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.beta2)
actioncable (= 7.2.0.beta2)
actionmailbox (= 7.2.0.beta2)
actionmailer (= 7.2.0.beta2)
actionpack (= 7.2.0.beta2)
actiontext (= 7.2.0.beta2)
actionview (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activemodel (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
rails (7.2.0)
actioncable (= 7.2.0)
actionmailbox (= 7.2.0)
actionmailer (= 7.2.0)
actionpack (= 7.2.0)
actiontext (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activemodel (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
bundler (>= 1.15.0)
railties (= 7.2.0.beta2)
railties (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
railties (= 7.2.0)
railties (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -109,8 +110,8 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.952.0)
aws-sdk-core (3.201.1)
aws-partitions (1.961.0)
aws-sdk-core (3.201.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
@@ -118,11 +119,11 @@ GEM
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.156.0)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
@@ -135,7 +136,7 @@ GEM
smart_properties
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.18.3)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.1.2)
racc
@@ -151,7 +152,7 @@ GEM
xpath (~> 3.2)
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -168,31 +169,37 @@ GEM
dotenv (= 3.1.2)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.5.0)
erb_lint (0.6.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
rainbow
rubocop
rubocop (>= 1)
smart_properties
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.1)
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.9.2)
faraday (2.10.1)
faraday-net_http (>= 2.0, < 3.2)
faraday-net_http (3.1.0)
logger
faraday-net_http (3.1.1)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
ffi (1.16.3)
ffi (1.17.0-aarch64-linux-gnu)
ffi (1.17.0-arm-linux-gnu)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86-linux-gnu)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fugit (1.11.0)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.0.0)
good_job (4.1.1)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -217,7 +224,7 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
image_processing (1.12.2)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.1)
@@ -253,10 +260,10 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (4.12.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.24.1)
mocha (2.4.0)
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
net-http (0.4.1)
@@ -271,27 +278,27 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.6-aarch64-linux)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.6-arm-linux)
nokogiri (1.16.7-arm-linux)
racc (~> 1.4)
nokogiri (1.16.6-arm64-darwin)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86-linux)
nokogiri (1.16.7-x86-linux)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-darwin)
nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-linux)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.6.3)
parallel (1.24.0)
parser (3.3.1.0)
pagy (9.0.5)
parallel (1.25.1)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
pg (1.5.7)
prism (0.30.0)
propshaft (0.9.0)
actionpack (>= 7.0.0)
@@ -304,8 +311,8 @@ GEM
puma (6.4.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.0)
rack (3.1.6)
racc (1.8.1)
rack (3.1.7)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -331,22 +338,23 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rbs (3.5.1)
rbs (3.5.2)
logger
rdoc (6.7.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
rexml (3.3.0)
rexml (3.3.4)
strscan
rubocop (1.63.5)
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
@@ -369,31 +377,33 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.4)
ruby-lsp (0.17.12)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.8)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-lsp-rails (0.3.12)
ruby-lsp (>= 0.17.12, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
selenium-webdriver (4.22.0)
securerandom (0.3.1)
selenium-webdriver (4.23.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.18.1)
sentry-rails (5.18.2)
railties (>= 5.0)
sentry-ruby (~> 5.18.1)
sentry-ruby (5.18.1)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -403,29 +413,29 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11473)
sorbet-runtime (0.5.11518)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.1)
tailwindcss-rails (2.7.2)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-aarch64-linux)
tailwindcss-rails (2.7.2-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-arm-linux)
tailwindcss-rails (2.7.2-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-arm64-darwin)
tailwindcss-rails (2.7.2-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-x86_64-darwin)
tailwindcss-rails (2.7.2-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-x86_64-linux)
tailwindcss-rails (2.7.2-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
timeout (0.4.1)
turbo-rails (2.0.5)
turbo-rails (2.0.6)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
@@ -445,13 +455,13 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
websocket (1.2.10)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.16)
zeitwerk (2.6.17)
PLATFORMS
aarch64-linux
@@ -491,6 +501,7 @@ DEPENDENCIES
puma (>= 5.0)
rails!
rails-settings-cached
redcarpet
rubocop-rails-omakase
ruby-lsp-rails
selenium-webdriver
@@ -507,7 +518,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.1p55
ruby 3.3.4p94
BUNDLED WITH
2.5.9

View File

@@ -42,7 +42,7 @@ The instructions below are for developers to get started with contributing to th
### Requirements
- Ruby 3.3.1
- Ruby 3.3.4
- PostgreSQL >9.3 (ideally, latest stable version)
After cloning the repo, the basic setup commands are:

View File

@@ -4,27 +4,27 @@
/* Reset rules, default styles applied to plain HTML */
@layer base {
details > summary::-webkit-details-marker {
details>summary::-webkit-details-marker {
@apply hidden;
}
details > summary {
details>summary {
@apply list-none;
}
}
@layer components {
.form-field {
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
@apply block text-xs text-gray-500;
}
.form-field__input {
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:opacity-50;
@@ -38,7 +38,7 @@
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked + label + .toggle-switch-dot {
input:checked+label+.toggle-switch-dot {
transform: translateX(100%);
}
@@ -58,6 +58,19 @@
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
select[multiple="multiple"] {
@apply py-2 pr-2 space-y-0.5;
}
select[multiple="multiple"] option {
@apply p-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@apply bg-gray-50;
@apply after:content-['\2713'] after:float-right after:text-gray-500;
}
.maybe-switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@@ -77,6 +90,10 @@
@apply font-bold;
}
}
.tooltip {
@apply hidden absolute;
}
}
/* Small, single purpose classes that should take precedence over other styles */

View File

@@ -0,0 +1,14 @@
class Account::CashesController < ApplicationController
layout :with_sidebar
before_action :set_account
def index
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
end

View File

@@ -1,48 +1,15 @@
class Account::EntriesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: %i[ edit update show destroy ]
def transactions
@transaction_entries = @account.entries.account_transactions.reverse_chronological
end
def valuations
@valuation_entries = @account.entries.account_valuations.reverse_chronological
end
def new
@entry = @account.entries.build.tap do |entry|
if params[:entryable_type]
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
else
entry.entryable = Account::Valuation.new
end
end
end
def create
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
if @entry.save
@entry.sync_account_later
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
render entryable_view_path(:edit)
end
def update
@entry.assign_attributes entry_params
@entry.amount = amount if nature.present?
@entry.save!
@entry.update!(entry_params)
@entry.sync_account_later
respond_to do |format|
@@ -52,6 +19,7 @@ class Account::EntriesController < ApplicationController
end
def show
render entryable_view_path(:show)
end
def destroy
@@ -62,6 +30,10 @@ class Account::EntriesController < ApplicationController
private
def entryable_view_path(action)
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
end
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
@@ -70,36 +42,7 @@ class Account::EntriesController < ApplicationController
@entry = @account.entries.find(params[:id])
end
def permitted_entryable_attributes
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
case entryable_type
when "Account::Transaction"
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
else
[ :id ]
end
end
def entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
end
def amount
if nature.income?
entry_params[:amount].to_d.abs * -1
else
entry_params[:amount].to_d.abs
end
end
def nature
params[:account_entry][:nature].to_s.inquiry
end
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
def entry_params_with_defaults(params)
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
params.require(:account_entry).permit(:name, :date, :amount, :currency)
end
end

View File

@@ -0,0 +1,23 @@
class Account::HoldingsController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_holding, only: :show
def index
@holdings = @account.holdings.current
end
def show
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_holding
@holding = @account.holdings.current.find(params[:id])
end
end

View File

@@ -0,0 +1,37 @@
class Account::TradesController < ApplicationController
layout :with_sidebar
before_action :set_account
def new
@entry = @account.entries.account_trades.new(entryable_attributes: {})
end
def index
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[ Account::Trade Account::Transaction ])
end
def create
@builder = Account::EntryBuilder.new(entry_params)
if entry = @builder.save
entry.sync_account_later
redirect_to account_path(@account), notice: t(".success")
else
flash[:alert] = t(".failure")
redirect_back_or_to account_path(@account)
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entry_params
params.require(:account_entry)
.permit(:type, :date, :qty, :ticker, :price, :amount, :currency, :transfer_account_id)
.merge(account: @account)
end
end

View File

@@ -0,0 +1,53 @@
class Account::TransactionsController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: :update
def index
@entries = @account.entries.account_transactions.reverse_chronological
end
def update
@entry.update!(entry_params.merge(amount: amount))
@entry.sync_account_later
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_entry
@entry = @account.entries.find(params[:id])
end
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :entryable_type,
entryable_attributes: [
:id,
:notes,
:excluded,
:category_id,
:merchant_id,
{ tag_ids: [] }
]
)
end
def amount
if params[:account_entry][:nature] == "income"
entry_params[:amount].to_d * -1
else
entry_params[:amount].to_d
end
end
end

View File

@@ -1,5 +1,5 @@
class Account::TransfersController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_transfer, only: :destroy
@@ -23,7 +23,7 @@ class Account::TransfersController < ApplicationController
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @transfer.errors.full_messages.to_sentence
flash[:alert] = @transfer.errors.full_messages.to_sentence
redirect_to transactions_path
end
end

View File

@@ -0,0 +1,35 @@
class Account::ValuationsController < ApplicationController
layout :with_sidebar
before_action :set_account
def new
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
end
def create
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
if @entry.save
@entry.sync_account_later
redirect_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def index
@entries = @account.entries.account_valuations.reverse_chronological
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency)
end
end

View File

@@ -1,5 +1,5 @@
class AccountsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
include Filterable
before_action :set_account, only: %i[ edit show destroy sync update ]
@@ -19,13 +19,11 @@ class AccountsController < ApplicationController
end
def list
render layout: false
end
def new
@account = Account.new(
balance: nil,
accountable: Accountable.from_type(params[:type])&.new
)
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
if params[:institution_id]
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
@@ -33,15 +31,18 @@ class AccountsController < ApplicationController
end
def show
@balance_series = @account.series(period: @period)
@series = @account.series(period: @period)
@trend = @series.trend
end
def edit
end
def update
@account.update! account_params.except(:accountable_type, :balance)
@account.update_balance!(account_params[:balance]) if account_params[:balance]
Account.transaction do
@account.update! account_params.except(:accountable_type, :balance)
@account.update_balance!(account_params[:balance]) if account_params[:balance]
end
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
end
@@ -68,8 +69,6 @@ class AccountsController < ApplicationController
unless @account.syncing?
@account.sync_later
end
redirect_to account_path(@account), notice: t(".success")
end
def sync_all

View File

@@ -1,9 +1,15 @@
class ApplicationController < ActionController::Base
include Authentication, Invitable, SelfHostable
include AutoSync, Authentication, Invitable, SelfHostable
include Pagy::Backend
default_form_builder ApplicationFormBuilder
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
private
def with_sidebar
return "turbo_rails/frame" if turbo_frame_request?
"with_sidebar"
end
end

View File

@@ -1,5 +1,5 @@
class CategoriesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_category, only: %i[ edit update ]
before_action :set_transaction, only: :create

View File

@@ -1,5 +1,5 @@
class Category::DeletionsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_category
before_action :set_replacement_category, only: :create

View File

@@ -3,13 +3,11 @@ module Authentication
included do
before_action :authenticate_user!
after_action :set_last_login_at, if: -> { Current.user }
end
class_methods do
def skip_authentication(**options)
skip_before_action :authenticate_user!, **options
skip_after_action :set_last_login_at, **options
end
end
@@ -27,6 +25,7 @@ module Authentication
Current.user = user
reset_session
session[:user_id] = user.id
set_last_login_at
end
def logout

View File

@@ -0,0 +1,13 @@
module AutoSync
extend ActiveSupport::Concern
included do
before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? }
end
private
def sync_family
Current.family.sync
end
end

View File

@@ -1,6 +1,6 @@
class CurrenciesController < ApplicationController
def show
@currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
render json: { step: @currency.step, placeholder: Money.new(0, @currency).format }
currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
render json: currency.as_json.merge({ step: currency.step })
end
end

View File

@@ -0,0 +1,11 @@
class Help::ArticlesController < ApplicationController
layout :with_sidebar
def show
@article = Help::Article.find(params[:id])
unless @article
head :not_found
end
end
end

View File

@@ -38,11 +38,26 @@ class ImportsController < ApplicationController
def load
end
def upload_csv
begin
@import.raw_csv_str = import_params[:raw_csv_str].read
rescue NoMethodError
flash.now[:alert] = "Please select a file to upload"
render :load, status: :unprocessable_entity and return
end
if @import.save
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:alert] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
end
def load_csv
if @import.update(import_params)
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:error] = @import.errors.full_messages.to_sentence
flash.now[:alert] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
end

View File

@@ -1,5 +1,5 @@
class MerchantsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_merchant, only: %i[ edit update destroy ]

View File

@@ -1,5 +1,5 @@
class PagesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
include Filterable

View File

@@ -19,7 +19,7 @@ class Settings::HostingsController < SettingsController
def send_test_email
unless Setting.smtp_settings_populated?
flash[:error] = t(".missing_smtp_setting_error")
flash[:alert] = t(".missing_smtp_setting_error")
render(:show, status: :unprocessable_entity)
return
end
@@ -27,7 +27,7 @@ class Settings::HostingsController < SettingsController
begin
NotificationMailer.with(user: Current.user).test_email.deliver_now
rescue => _e
flash[:error] = t(".error")
flash[:alert] = t(".error")
render :show, status: :unprocessable_entity
return
end

View File

@@ -17,7 +17,7 @@ class Settings::ProfilesController < SettingsController
if Current.user.update(user_params_with_family)
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: t(".file_size_error")
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end

View File

@@ -1,3 +1,3 @@
class SettingsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
end

View File

@@ -1,5 +1,5 @@
class Tag::DeletionsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_tag
before_action :set_replacement_tag, only: :create

View File

@@ -1,5 +1,5 @@
class TagsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_tag, only: %i[ edit update ]

View File

@@ -1,10 +1,10 @@
class TransactionsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
def index
@q = search_params
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
@pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50")
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
@totals = {
count: result.select { |t| t.currency == Current.family.currency }.count,

View File

@@ -0,0 +1,13 @@
module Account::CashesHelper
def brokerage_cash(account)
currency = Money::Currency.new(account.currency)
account.holdings.build \
date: Date.current,
qty: account.balance,
price: 1,
amount: account.balance,
currency: account.currency,
security: Security.new(ticker: currency.iso_code, name: currency.name)
end
end

View File

@@ -30,10 +30,32 @@ module Account::EntriesHelper
mixed_hex_styles(color)
end
def entry_name(entry)
if entry.account_trade?
trade = entry.account_trade
prefix = trade.sell? ? "Sell " : "Buy "
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
name = entry.name || generated
name
else
entry.name || "Transaction"
end
end
def entries_by_date(entries, selectable: true)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
end
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
end.join.html_safe
end
private
def permitted_entryable_key(entry)
permitted_entryable_paths = %w[transaction valuation]
permitted_entryable_paths = %w[transaction valuation trade]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View File

@@ -23,18 +23,38 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
def account_tabs(account)
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) }
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
[ value_tab, transactions_tab ]
end
def selected_account_tab(account)
available_tabs = account_tabs(account)
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
tab || available_tabs.first
end
private
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
end

View File

@@ -1,149 +0,0 @@
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
def initialize(object_name, object, template, options)
options[:html] ||= {}
options[:html][:class] ||= "space-y-4"
super(object_name, object, template, options)
end
(field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
default_options = { class: "form-field__input" }
merged_options = default_options.merge(options)
return super(method, merged_options) unless options[:label]
@template.form_field_tag do
label(method, *label_args(options)) +
super(method, merged_options.except(:label))
end
end
RUBY_EVAL
end
# See `Monetizable` concern, which adds a _money suffix to the attribute name
# For a monetized field, the setter will always be the attribute name without the _money suffix
def money_field(method, options = {})
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
money_amount_method = method.to_s.chomp("_money").to_sym
money_currency_method = :currency
readonly_currency = options[:readonly_currency] || false
currency = money&.currency || Money::Currency.new(Current.family.currency) || Money.default_currency
default_options = {
class: "form-field__input",
value: money&.amount,
"data-money-field-target" => "amount",
placeholder: Money.new(0, currency).format,
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = default_options.merge(options)
grouped_options = currency_options_for_select
selected_currency = money&.currency&.iso_code || currency.iso_code
@template.form_field_tag data: { controller: "money-field" } do
(label(method, *label_args(options)).to_s if options[:label]) +
@template.tag.div(class: "flex items-center") do
number_field(money_amount_method, merged_options.except(:label)) +
grouped_select(money_currency_method, grouped_options, { selected: selected_currency }, disabled: readonly_currency, class: "ml-auto form-field__input w-fit pr-8", data: { "money-field-target" => "currency", action: "change->money-field#handleCurrencyChange" })
end
end
end
def radio_button(method, tag_value, options = {})
default_options = { class: "form-field__radio" }
merged_options = default_options.merge(options)
super(method, tag_value, merged_options)
end
def grouped_select(method, grouped_choices, options = {}, html_options = {})
default_options = { class: "form-field__input" }
merged_html_options = default_options.merge(html_options)
label_html = label(method, *label_args(options)).to_s if options[:label]
select_html = @template.grouped_collection_select(@object_name, method, grouped_choices, :last, :first, :last, :first, options, merged_html_options)
@template.content_tag(:div, class: "flex items-center") do
label_html.to_s.html_safe + select_html
end
end
def currency_select(method, options = {}, html_options = {})
default_options = { class: "form-field__input" }
merged_options = default_options.merge(html_options)
choices = currency_options_for_select
return @template.grouped_collection_select(@object_name, method, choices, :last, :first, :last, :first, options, merged_options) unless options[:label]
@template.form_field_tag do
label(method, *label_args(options)) +
@template.grouped_collection_select(@object_name, method, choices, :last, :first, :last, :first, options, merged_options.except(:label))
end
end
def select(method, choices, options = {}, html_options = {})
default_options = { class: "form-field__input" }
merged_options = default_options.merge(html_options)
return super(method, choices, options, merged_options) unless options[:label]
@template.form_field_tag do
label(method, *label_args(options)) +
super(method, choices, options, merged_options.except(:label))
end
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
default_options = { class: "form-field__input" }
merged_options = default_options.merge(html_options)
return super(method, collection, value_method, text_method, options, merged_options) unless options[:label]
@template.form_field_tag do
label(method, *label_args(options)) +
super(method, collection, value_method, text_method, options, merged_options.except(:label))
end
end
def submit(value = nil, options = {})
value, options = nil, value if value.is_a?(Hash)
default_options = { class: "form-field__submit" }
merged_options = default_options.merge(options)
super(value, merged_options)
end
private
def currency_options_for_select
popular_currencies = Money::Currency.popular.map { |currency| [ currency.iso_code, currency.iso_code ] }
all_currencies = Money::Currency.all_instances.map { |currency| [ currency.iso_code, currency.iso_code ] }
all_other_currencies = all_currencies.reject { |c| popular_currencies.map(&:last).include?(c.last) }.sort_by(&:last)
{
I18n.t("accounts.new.currency.popular") => popular_currencies,
I18n.t("accounts.new.currency.all_others") => all_other_currencies
}
end
def label_args(options)
case options[:label]
when Array
options[:label]
when String
[ options[:label], { class: "form-field__label" } ]
when Hash
[ nil, options[:label] ]
else
[ nil, { class: "form-field__label" } ]
end
end
end

View File

@@ -13,11 +13,22 @@ module ApplicationHelper
name.underscore
end
def notification(text, **options, &block)
content = tag.p(text)
content = capture &block if block_given?
def family_notifications_stream
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
def family_stream
turbo_stream_from Current.family if Current.family
end
def render_flash_notifications
notifications = flash.flat_map do |type, message_or_messages|
Array(message_or_messages).map do |message|
render partial: "shared/notification", locals: { type: type, message: message }
end
end
safe_join(notifications)
end
##

View File

@@ -1,7 +1,18 @@
module FormsHelper
def styled_form_with(**options, &block)
options[:builder] = StyledFormBuilder
form_with(**options, &block)
end
def modal_form_wrapper(title:, subtitle: nil, &block)
content = capture &block
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
end
def form_field_tag(options = {}, &block)
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
tag.div **options, &block
tag.div(**options, &block)
end
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
@@ -11,23 +22,60 @@ module FormsHelper
end
end
def selectable_categories
Current.family.categories.alphabetically
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ], [ "All", "all" ] ]
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
end
def selectable_merchants
Current.family.merchants.alphabetically
def money_with_currency_field(form, money_method, options = {})
render partial: "shared/money_field", locals: {
form: form,
money_method: money_method,
default_currency: options[:default_currency] || "USD",
disable_currency: options[:disable_currency] || false,
hide_currency: options[:hide_currency] || false,
label: options[:label] || "Amount"
}
end
def selectable_accounts
Current.family.accounts.alphabetically
def money_field(form, method, options = {})
value = form.object ? form.object.send(method) : nil
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
# See "Monetizable" concern
money_amount_method = method.to_s.chomp("_money").to_sym
money_options = {
value: value&.amount,
placeholder: "100",
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = options.merge(money_options)
form.number_field money_amount_method, merged_options
end
def selectable_tags
Current.family.tags.alphabetically.pluck(:name, :id)
def currency_select_full(form, method, options = {}, html_options = {}, &block)
choices = currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }
form.select method, choices, options, html_options, &block
end
def currency_select(form, method, options = {}, html_options = {}, &block)
choices = currencies_for_select.map(&:iso_code)
form.select method, choices, options, html_options, &block
end
private
def currencies_for_select
Money::Currency.all_instances
.sort_by(&:priority)
end
def radio_tab_contents(label:, icon:)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
concat lucide_icon(icon, class: "w-5 h-5")

View File

@@ -0,0 +1,58 @@
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 = {})
input_html = label_html(method, options) + super(method, merged_options(options))
input_html = apply_form_field_wrapper(input_html) unless options[:inline]
input_html
end
RUBY_EVAL
end
def radio_button(method, tag_value, options = {})
super(method, tag_value, merged_options(options, "form-field__radio"))
end
def select(method, choices, options = {}, html_options = {})
input_html = label_html(method, options) + super(method, choices, options, merged_options(html_options))
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
input_html
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
input_html = label_html(method, options) + super(method, collection, value_method, text_method, options, merged_options(html_options))
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
input_html
end
def submit(value = nil, options = {})
value, options = nil, value if value.is_a?(Hash)
super(value, merged_options(options, "form-field__submit"))
end
private
def apply_form_field_wrapper(input_html, **options)
@template.form_field_tag(**options) do
input_html
end
end
def merged_options(options, default_class = "form-field__input")
combined_classes = options.fetch(:class, "") + " #{default_class}"
style_options = { class: combined_classes }
non_custom_options = options.except(:class, :label, :inline)
style_options.merge(non_custom_options)
end
def label_html(method, options)
return label(method, class: "form-field__label") if options[:label] == true
return "".html_safe unless options[:label]
label(method, options[:label], class: "form-field__label")
end
end

View File

@@ -1,17 +1,13 @@
module ValueGroupsHelper
def value_group_pie_data(value_group)
value_group.children
.map do |child|
{
label: to_accountable_title(Accountable.from_type(child.name)),
percent_of_total: child.percent_of_total.round(1).to_f,
value: child.sum.amount.to_f,
currency: child.sum.currency.iso_code,
bg_color: accountable_bg_class(child.name),
fill_color: accountable_fill_class(child.name)
}
end
.filter { |child| child[:value] > 0 }
.to_json
value_group.children.filter { |c| c.sum > 0 }.map do |child|
{
label: to_accountable_title(Accountable.from_type(child.name)),
percent_of_total: child.percent_of_total.round(1).to_f,
formatted_value: format_money(child.sum, precision: 0),
bg_color: accountable_bg_class(child.name),
fill_color: accountable_fill_class(child.name)
}
end.to_json
end
end

View File

@@ -59,6 +59,7 @@ export default class extends Controller {
deselectAll() {
this.selectedIdsValue = []
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
}
selectedIdsValueChanged() {

View File

@@ -0,0 +1,94 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "preview", "submit", "filename", "filesize"]
connect() {
this.submitTarget.disabled = true
}
addFile(event) {
const file = event.target.files[0]
this._fileAdded(file)
}
dragover(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.add("bg-gray-100")
}
dragleave(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.remove("bg-gray-100")
}
drop(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.remove("bg-gray-100")
const file = event.dataTransfer.files[0]
if (file && this._isCSVFile(file)) {
this._setFileInput(file);
this._fileAdded(file)
} else {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = "Only CSV files are allowed."
}
}
// Private
_fetchFileSize(size) {
let fileSize = '';
if (size < 1024 * 1024) {
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
} else {
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
}
return fileSize;
}
_fileAdded(file) {
const fileSizeLimit = 5 * 1024 * 1024 // 5MB
if (file) {
if (file.size > fileSizeLimit) {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
return
}
this.submitTarget.classList.remove([
"bg-alpha-black-25",
"text-gray",
"cursor-not-allowed",
]);
this.submitTarget.classList.add(
"bg-gray-900",
"text-white",
"cursor-pointer",
);
this.submitTarget.disabled = false;
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
this.previewTarget.classList.remove("text-red-500")
this.previewTarget.classList.add("text-gray-900")
this.filenameTarget.textContent = file.name;
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
}
}
_isCSVFile(file) {
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
const extension = file.name.split('.').pop().toLowerCase()
return acceptedTypes.includes(file.type) || extension === "csv"
}
_setFileInput(file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.inputTarget.files = dataTransfer.files;
}
}

View File

@@ -4,17 +4,23 @@ import { CurrenciesService } from "services/currencies_service";
// Connects to data-controller="money-field"
// when currency select change, update the input value with the correct placeholder and step
export default class extends Controller {
static targets = ["amount", "currency"];
static targets = ["amount", "currency", "symbol"];
handleCurrencyChange() {
const selectedCurrency = event.target.value;
handleCurrencyChange(e) {
const selectedCurrency = e.target.value;
this.updateAmount(selectedCurrency);
}
updateAmount(currency) {
(new CurrenciesService).get(currency).then((data) => {
this.amountTarget.placeholder = data.placeholder;
this.amountTarget.step = data.step;
(new CurrenciesService).get(currency).then((currency) => {
console.log(currency)
this.amountTarget.step = currency.step;
if (isFinite(this.amountTarget.value)) {
this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision)
}
this.symbolTarget.innerText = currency.symbol;
});
}
}

View File

@@ -5,6 +5,7 @@ import * as d3 from "d3";
export default class extends Controller {
static values = {
data: Array,
total: String,
label: String,
};
@@ -38,7 +39,7 @@ export default class extends Controller {
#draw() {
this.#d3Container.attr("class", "relative");
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
this.#d3Content.html(this.#contentSummaryTemplate());
const pie = d3
.pie()
@@ -75,23 +76,17 @@ export default class extends Controller {
this.#d3Svg
.selectAll(".arc path")
.attr("class", (d) => d.data.fill_color);
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
this.#d3ContentMemo.html(this.#contentSummaryTemplate());
});
}
#contentSummaryTemplate(data) {
const total = data.reduce((acc, cur) => acc + cur.value, 0);
const currency = data[0].currency;
return `${this.#currencyValue({
value: total,
currency,
})} <span class="text-xs">${this.labelValue}</span>`;
#contentSummaryTemplate() {
return `<span class="text-xl text-gray-900 font-medium">${this.totalValue}</span> <span class="text-xs">${this.labelValue}</span>`;
}
#contentDetailTemplate(datum) {
return `
<span>${this.#currencyValue(datum)}</span>
<span class="text-xl text-gray-900 font-medium">${datum.formatted_value}</span>
<div class="flex flex-row text-xs gap-2 items-center">
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
<span>${datum.label}</span>
@@ -100,21 +95,6 @@ export default class extends Controller {
`;
}
#currencyValue(datum) {
const formattedValue = Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
currencyDisplay: "narrowSymbol",
}).format(datum.value);
const firstDigitIndex = formattedValue.search(/\d/);
const currencyPrefix = formattedValue.substring(0, firstDigitIndex);
const mainPart = formattedValue.substring(firstDigitIndex);
const [integerPart, fractionalPart] = mainPart.split(".");
return `<p class="text-gray-500 -space-x-0.5">${currencyPrefix}<span class="text-xl text-gray-900 font-medium">${integerPart}</span>.${fractionalPart}</p>`;
}
get #radius() {
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
}

View File

@@ -1,179 +0,0 @@
import { Controller } from "@hotwired/stimulus";
/**
* A custom "select" element that follows accessibility patterns of a native select element.
*
* - If you need to display arbitrary content including non-clickable items, links, buttons, and forms, use the "popover" controller instead.
*/
export default class extends Controller {
static classes = ["active"];
static targets = ["option", "button", "list", "input", "buttonText"];
static values = { selected: String };
initialize() {
this.show = false;
const selectedElement = this.optionTargets.find(
(option) => option.dataset.value === this.selectedValue
);
if (selectedElement) {
this.updateAriaAttributesAndClasses(selectedElement);
this.syncButtonTextWithInput();
}
}
connect() {
this.syncButtonTextWithInput();
if (this.hasButtonTarget) {
this.buttonTarget.addEventListener("click", this.toggleList);
}
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
this.element.addEventListener("turbo:load", this.handleTurboLoad);
}
disconnect() {
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
if (this.hasButtonTarget) {
this.buttonTarget.removeEventListener("click", this.toggleList);
}
}
selectedValueChanged() {
this.syncButtonTextWithInput();
}
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) {
this.close();
}
};
handleTurboLoad = () => {
this.close();
this.syncButtonTextWithInput();
};
handleKeydown = (event) => {
switch (event.key) {
case " ":
case "Enter":
event.preventDefault(); // Prevent the default action to avoid scrolling
if (
this.hasButtonTarget &&
document.activeElement === this.buttonTarget
) {
this.toggleList();
} else {
this.selectOption(event);
}
break;
case "ArrowDown":
event.preventDefault(); // Prevent the default action to avoid scrolling
this.focusNextOption();
break;
case "ArrowUp":
event.preventDefault(); // Prevent the default action to avoid scrolling
this.focusPreviousOption();
break;
case "Escape":
this.close();
if (this.hasButtonTarget) {
this.buttonTarget.focus(); // Bring focus back to the button
}
break;
case "Tab":
this.close();
break;
}
};
focusNextOption() {
this.focusOptionInDirection(1);
}
focusPreviousOption() {
this.focusOptionInDirection(-1);
}
focusOptionInDirection(direction) {
const currentFocusedIndex = this.optionTargets.findIndex(
(option) => option === document.activeElement
);
const optionsCount = this.optionTargets.length;
const nextIndex =
(currentFocusedIndex + direction + optionsCount) % optionsCount;
this.optionTargets[nextIndex].focus();
}
toggleList = () => {
if (!this.hasButtonTarget) return; // Ensure button target is present before toggling
this.show = !this.show;
this.listTarget.classList.toggle("hidden", !this.show);
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
if (this.show) {
// Focus the first option or the selected option when the list is shown
const selectedOption = this.optionTargets.find(
(option) => option.getAttribute("aria-selected") === "true"
);
(selectedOption || this.optionTargets[0]).focus();
}
};
close() {
if (this.hasButtonTarget) {
this.show = false;
this.listTarget.classList.add("hidden");
this.buttonTarget.setAttribute("aria-expanded", "false");
}
}
selectOption(event) {
const selectedOption =
event.type === "keydown" ? document.activeElement : event.currentTarget;
this.updateAriaAttributesAndClasses(selectedOption);
if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) {
this.updateInputValueAndEmitEvent(selectedOption);
}
this.close(); // Close the list after selection
}
updateAriaAttributesAndClasses(selectedOption) {
this.optionTargets.forEach((option) => {
option.setAttribute("aria-selected", "false");
option.setAttribute("tabindex", "-1");
option.classList.remove(...this.activeClasses);
});
selectedOption.classList.add(...this.activeClasses);
selectedOption.setAttribute("aria-selected", "true");
selectedOption.focus();
}
updateInputValueAndEmitEvent(selectedOption) {
// Update the hidden input's value
const selectedValue = selectedOption.getAttribute("data-value");
this.inputTarget.value = selectedValue;
this.syncButtonTextWithInput();
// Emit an input event for auto-submit functionality
const inputEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
this.inputTarget.dispatchEvent(inputEvent);
}
syncButtonTextWithInput() {
const matchingOption = this.optionTargets.find(
(option) => option.getAttribute("data-value") === this.inputTarget.value
);
if (matchingOption && this.hasButtonTextTarget) {
this.buttonTextTarget.textContent = matchingOption.textContent.trim();
}
}
}

View File

@@ -0,0 +1,74 @@
import { Controller } from '@hotwired/stimulus'
import {
computePosition,
flip,
shift,
offset,
arrow
} from '@floating-ui/dom';
export default class extends Controller {
static targets = ["arrow", "tooltip"];
static values = {
placement: { type: String, default: "top" },
offset: { type: Number, default: 10 },
crossAxis: { type: Number, default: 0 },
alignmentAxis: { type: Number, default: null },
};
connect() {
this.element.addEventListener("mouseenter", this.showTooltip);
this.element.addEventListener("mouseleave", this.hideTooltip);
this.element.addEventListener("focus", this.showTooltip);
this.element.addEventListener("blur", this.hideTooltip);
};
showTooltip = () => {
this.tooltipTarget.style.display = 'block';
this.#update();
};
hideTooltip = () => {
this.tooltipTarget.style.display = '';
};
disconnect() {
this.element.removeEventListener("mouseenter", this.showTooltip);
this.element.removeEventListener("mouseleave", this.hideTooltip);
this.element.removeEventListener("focus", this.showTooltip);
this.element.removeEventListener("blur", this.hideTooltip);
};
#update() {
computePosition(this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
flip(),
shift({ padding: 5 }),
arrow({ element: this.arrowTarget }),
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltipTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
Object.assign(this.arrowTarget.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
});
});
};
}

View File

@@ -0,0 +1,64 @@
import {Controller} from "@hotwired/stimulus"
const TRADE_TYPES = {
BUY: "buy",
SELL: "sell",
TRANSFER_IN: "transfer_in",
TRANSFER_OUT: "transfer_out",
INTEREST: "interest"
}
const FIELD_VISIBILITY = {
[TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true},
[TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true},
[TRADE_TYPES.INTEREST]: {amount: true}
}
// Connects to data-controller="trade-form"
export default class extends Controller {
static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"]
connect() {
this.handleTypeChange = this.handleTypeChange.bind(this)
this.typeInputTarget.addEventListener("change", this.handleTypeChange)
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY)
}
disconnect() {
this.typeInputTarget.removeEventListener("change", this.handleTypeChange)
}
handleTypeChange(event) {
this.updateFields(event.target.value)
}
updateFields(type) {
const visibleFields = FIELD_VISIBILITY[type] || {}
Object.entries(this.fieldTargets).forEach(([field, target]) => {
const isVisible = visibleFields[field] || false
// Update visibility
target.hidden = !isVisible
// Update required status based on visibility
if (isVisible) {
target.setAttribute('required', '')
} else {
target.removeAttribute('required')
}
})
}
get fieldTargets() {
return {
ticker: this.tickerInputTarget,
amount: this.amountInputTarget,
transferAccount: this.transferAccountInputTarget,
qty: this.qtyInputTarget,
price: this.priceInputTarget
}
}
}

View File

@@ -2,9 +2,7 @@ class Account < ApplicationRecord
include Syncable
include Monetizable
broadcasts_refreshes
validates :family, presence: true
validates :name, :balance, :currency, presence: true
belongs_to :family
belongs_to :institution, optional: true
@@ -12,6 +10,8 @@ class Account < ApplicationRecord
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
@@ -29,7 +29,7 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
class << self
def by_group(period: Period.all, currency: Money.default_currency)
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types|
@@ -73,6 +73,31 @@ class Account < ApplicationRecord
end
end
# Start of temporary fix for #1068
# ==========================================================================
# TODO: Both `series` and `value` methods are a temporary fix for #1068, which appears to be a data corruption issue.
# Every account should have an accountable no matter what, but some self hosted instances seem to have missing accountables.
# When this is fixed, we can add this back to `delegate :value, :series, to: :accountable`
def series(period: Period.all, currency: self.currency)
if accountable.present?
accountable.series(period: period, currency: currency)
else
TimeSeries.new([])
end
end
def value
if accountable.present?
accountable.value
else
balance_money
end
end
# ==========================================================================
# End of temporary fix for #1068
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
@@ -82,18 +107,6 @@ class Account < ApplicationRecord
classification == "asset" ? "up" : "down"
end
def series(period: Period.all, currency: self.currency)
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
end
rescue Money::ConversionError
TimeSeries.new([])
end
def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current)
@@ -107,4 +120,12 @@ class Account < ApplicationRecord
entryable: Account::Valuation.new
end
end
def holding_qty(security, date: Date.current)
entries.account_trades
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
.where(account_trades: { security_id: security.id })
.where("account_entries.date <= ?", date)
.sum("account_trades.qty")
end
end

View File

@@ -27,13 +27,9 @@ class Account::Balance::Syncer
attr_reader :sync_start_date, :account
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
{
date: balance.date,
balance: balance.balance,
currency: balance.currency,
updated_at: Time.now
}
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
end
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
@@ -49,9 +45,9 @@ class Account::Balance::Syncer
return valuation.amount if valuation
return derived_sync_start_balance(entries) unless prior_balance
transactions = entries.select { |e| e.date == date && e.account_transaction? }
entries = entries.select { |e| e.date == date }
prior_balance - net_transaction_flows(transactions)
prior_balance - net_entry_flows(entries)
end
def calculate_daily_balances
@@ -95,19 +91,19 @@ class Account::Balance::Syncer
end
def derived_sync_start_balance(entries)
transactions = entries.select { |e| e.account_transaction? && e.date > sync_start_date }
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
account.balance + net_transaction_flows(transactions)
account.balance + net_entry_flows(transactions_and_trades)
end
def find_prior_balance
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
end
def net_transaction_flows(transactions, target_currency = account.currency)
converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
def net_entry_flows(entries, target_currency = account.currency)
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_transaction_amounts.sum(&:amount)
flows = converted_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end

View File

@@ -11,6 +11,8 @@ class Account::Entry < ApplicationRecord
validates :date, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
validate :trade_valid?, if: -> { account_trade? }
scope :chronological, -> { order(:date, :created_at) }
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
@@ -63,6 +65,11 @@ class Account::Entry < ApplicationRecord
end
class << self
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
10.years.ago.to_date
end
def daily_totals(entries, currency, period: Period.last_30_days)
# Sum spending and income for each day in the period with the given currency
select(
@@ -123,7 +130,7 @@ class Account::Entry < ApplicationRecord
def income_total(currency = "USD")
without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
.where("account_entries.amount <= 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
end
@@ -137,7 +144,7 @@ class Account::Entry < ApplicationRecord
def search(params)
query = all
query = query.where("account_entries.name ILIKE ?", "%#{params[:search]}%") if params[:search].present?
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
@@ -191,4 +198,21 @@ class Account::Entry < ApplicationRecord
previous: previous_entry&.amount_money,
favorable_direction: account.favorable_direction
end
def trade_valid?
if account_trade.sell?
current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs
# i18n-tasks-use t('activerecord.errors.models.account/entry.attributes.base.invalid_sell_quantity')
errors.add(
:base,
:invalid_sell_quantity,
sell_qty: account_trade.qty.abs,
ticker: account_trade.security.ticker,
current_qty: current_qty
)
end
end
end
end

View File

@@ -0,0 +1,45 @@
class Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[ income expense buy sell interest transfer_in transfer_out ].freeze
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_builder.save
end
end
private
def create_builder
case type
when "buy", "sell"
create_trade_builder
else
create_transaction_builder
end
end
def create_trade_builder
Account::TradeBuilder.new \
type: type,
date: date,
qty: qty,
ticker: ticker,
price: price,
account: account
end
def create_transaction_builder
Account::TransactionBuilder.new \
type: type,
date: date,
amount: amount,
account: account,
transfer_account_id: transfer_account_id
end
end

View File

@@ -1,7 +1,7 @@
module Account::Entryable
extend ActiveSupport::Concern
TYPES = %w[ Account::Valuation Account::Transaction ]
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
def self.from_type(entryable_type)
entryable_type.presence_in(TYPES).constantize

View File

@@ -0,0 +1,48 @@
class Account::Holding < ApplicationRecord
include Monetizable
monetize :amount
belongs_to :account
belongs_to :security
validates :qty, :currency, presence: true
scope :chronological, -> { order(:date) }
scope :current, -> { where(date: Date.current).order(amount: :desc) }
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
scope :known_value, -> { where.not(amount: nil) }
scope :for, ->(security) { where(security_id: security).order(:date) }
delegate :name, to: :security
delegate :ticker, to: :security
def weight
return nil unless amount
portfolio_value = account.holdings.current.known_value.sum(&:amount)
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
end
# Basic approximation of cost-basis
def avg_cost
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
Money.new(avg_cost, currency)
end
def trend
@trend ||= calculate_trend
end
private
def calculate_trend
return nil unless amount_money
start_amount = qty * avg_cost
TimeSeries::Trend.new \
current: amount_money,
previous: start_amount
end
end

View File

@@ -0,0 +1,128 @@
class Account::Holding::Syncer
attr_reader :warnings
def initialize(account, start_date: nil)
@account = account
@warnings = []
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
@portfolio = {}
load_prior_portfolio if start_date
end
def run
holdings = []
sync_date_range.each do |date|
holdings += build_holdings_for_date(date)
end
upsert_holdings holdings
end
private
attr_reader :account, :sync_date_range
def sync_entries
@sync_entries ||= account.entries
.account_trades
.includes(entryable: :security)
.where("date >= ?", sync_date_range.begin)
.order(:date)
end
def get_cached_price(ticker, date)
return nil unless security_prices.key?(ticker)
price = security_prices[ticker].find { |p| p.date == date }
price ? price[:price] : nil
end
def security_prices
@security_prices ||= begin
prices = {}
ticker_start_dates = {}
sync_entries.each do |entry|
unless ticker_start_dates[entry.account_trade.security.ticker]
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
end
end
ticker_start_dates.each do |ticker, date|
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
end
prices
end
end
def build_holdings_for_date(date)
trades = sync_entries.select { |trade| trade.date == date }
@portfolio = generate_next_portfolio(@portfolio, trades)
@portfolio.map do |ticker, holding|
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
trade_price = trade&.account_trade&.price
price = get_cached_price(ticker, date) || trade_price
account.holdings.build \
date: date,
security_id: holding[:security_id],
qty: holding[:qty],
price: price,
amount: price ? (price * holding[:qty]) : nil,
currency: holding[:currency]
end
end
def generate_next_portfolio(prior_portfolio, trade_entries)
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
trade = entry.account_trade
price = trade.price
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
new_qty = prior_qty + trade.qty
new_portfolio[trade.security.ticker] = {
qty: new_qty,
price: price,
amount: new_qty * price,
currency: entry.currency,
security_id: trade.security_id
}
end
end
def upsert_holdings(holdings)
current_time = Time.now
holdings_to_upsert = holdings.map do |holding|
holding.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("updated_at" => current_time)
end
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
end
def load_prior_portfolio
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
prior_day_holdings.each do |holding|
@portfolio[holding.security.ticker] = {
qty: holding.qty,
price: holding.price,
amount: holding.amount,
currency: holding.currency,
security_id: holding.security_id
}
end
end
def calculate_sync_start_date(start_date)
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
end
end

View File

@@ -17,6 +17,7 @@ class Account::Sync < ApplicationRecord
start!
sync_balances
sync_holdings
complete!
rescue StandardError => error
@@ -33,19 +34,50 @@ class Account::Sync < ApplicationRecord
append_warnings(syncer.warnings)
end
def sync_holdings
syncer = Account::Holding::Syncer.new(account, start_date: start_date)
syncer.run
append_warnings(syncer.warnings)
end
def append_warnings(new_warnings)
update! warnings: warnings + new_warnings
end
def start!
update! status: "syncing", last_ran_at: Time.now
broadcast_start
end
def complete!
update! status: "completed"
broadcast_result type: "notice", message: "Sync complete"
end
def fail!(error)
update! status: "failed", error: error.message
broadcast_result type: "alert", message: error.message
end
def broadcast_start
broadcast_append_to(
[ account.family, :notifications ],
target: "notification-tray",
partial: "shared/notification",
locals: { id: id, type: "processing", message: "Syncing account balances" }
)
end
def broadcast_result(type:, message:)
broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification
broadcast_append_to(
[ account.family, :notifications ],
target: "notification-tray",
partial: "shared/notification",
locals: { type: type, message: message }
)
account.family.broadcast_refresh
end
end

View File

@@ -11,6 +11,14 @@ module Account::Syncable
syncs.syncing.any?
end
def latest_sync_date
syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date
end
def needs_sync?
latest_sync_date.nil? || latest_sync_date < Date.current
end
def sync_later(start_date: nil)
AccountSyncJob.perform_later(self, start_date: start_date)
end

View File

@@ -0,0 +1,28 @@
class Account::Trade < ApplicationRecord
include Account::Entryable, Monetizable
monetize :price
belongs_to :security
validates :qty, presence: true, numericality: { other_than: 0 }
validates :price, :currency, presence: true
class << self
def search(_params)
all
end
def requires_search?(_params)
false
end
end
def sell?
qty < 0
end
def buy?
qty > 0
end
end

View File

@@ -0,0 +1,46 @@
class Account::TradeBuilder < Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[ buy sell ].freeze
attr_accessor :type, :qty, :price, :ticker, :date, :account
validates :type, :qty, :price, :ticker, :date, presence: true
validates :price, numericality: { greater_than: 0 }
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_entry
end
end
private
def create_entry
account.entries.account_trades.create! \
date: date,
amount: amount,
currency: account.currency,
entryable: Account::Trade.new(
security: security,
qty: signed_qty,
price: price.to_d,
currency: account.currency
)
end
def security
Security.find_or_create_by(ticker: ticker)
end
def amount
price.to_d * signed_qty
end
def signed_qty
_qty = qty.to_d
_qty = _qty * -1 if type == "sell"
_qty
end
end

View File

@@ -0,0 +1,63 @@
class Account::TransactionBuilder
include ActiveModel::Model
TYPES = %w[ income expense interest transfer_in transfer_out ].freeze
attr_accessor :type, :amount, :date, :account, :transfer_account_id
validates :type, :amount, :date, presence: true
validates :type, inclusion: { in: TYPES }
def save
if valid?
transfer? ? create_transfer : create_transaction
end
end
private
def transfer?
%w[transfer_in transfer_out].include?(type)
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) unless transfer_account_id
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
Account::Transfer.create! entries: [ outflow, inflow ]
inflow
end
def create_unlinked_transfer(account_id, amount)
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
end
def create_transaction
build_entry(account.id, signed_amount).tap(&:save!)
end
def build_entry(account_id, amount, marked_as_transfer: false)
Account::Entry.new \
account_id: account_id,
amount: amount,
currency: account.currency,
date: date,
marked_as_transfer: marked_as_transfer,
entryable: Account::Transaction.new
end
def signed_amount
case type
when "expense", "transfer_out"
amount.to_d
else
amount.to_d * -1
end
end
end

View File

@@ -9,20 +9,19 @@ class Account::Transfer < ApplicationRecord
end
def amount_money
entries.first&.amount_money&.abs
entries.first&.amount_money&.abs || Money.new(0)
end
def from_name
outflow_transaction&.account&.name
outflow_transaction&.account&.name || I18n.t("account/transfer.from_fallback_name")
end
def to_name
inflow_transaction&.account&.name
inflow_transaction&.account&.name || I18n.t("account/transfer.to_fallback_name")
end
def name
return nil unless from_name && to_name
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
end
def inflow_transaction
@@ -73,24 +72,28 @@ class Account::Transfer < ApplicationRecord
def transaction_count
unless entries.size == 2
errors.add :entries, "must have exactly 2 entries"
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_exactly_2_entries')
errors.add :entries, :must_have_exactly_2_entries
end
end
def from_different_accounts
accounts = entries.map { |e| e.account_id }.uniq
errors.add :entries, "must be from different accounts" if accounts.size < entries.size
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_from_different_accounts')
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
end
def net_zero_flows
unless entries.sum(&:amount).zero?
errors.add :transactions, "must have an inflow and outflow that net to zero"
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_an_inflow_and_outflow_that_net_to_zero')
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
end
end
def all_transactions_marked
unless entries.all?(&:marked_as_transfer)
errors.add :entries, "must be marked as transfer"
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_marked_as_transfer')
errors.add :entries, :must_be_marked_as_transfer
end
end
end

View File

@@ -17,4 +17,20 @@ module Accountable
included do
has_one :account, as: :accountable, touch: true
end
def value
account.balance_money
end
def series(period: Period.all, currency: account.currency)
balance_series = account.balances.in_period(period).where(currency: currency)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
end
rescue Money::ConversionError
TimeSeries.new([])
end
end

View File

@@ -6,18 +6,25 @@ module Providable
extend ActiveSupport::Concern
class_methods do
def exchange_rates_provider
api_key = ENV["SYNTH_API_KEY"]
def security_prices_provider
synth_provider
end
if api_key.present?
Provider::Synth.new api_key
else
nil
end
def exchange_rates_provider
synth_provider
end
def git_repository_provider
Provider::Github.new
end
private
def synth_provider
@synth_provider ||= begin
api_key = ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
end
end
end

View File

@@ -1,5 +1,5 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
delegate :family, to: :user
delegate :family, to: :user, allow_nil: true
end

View File

@@ -13,31 +13,29 @@ class Demo::Generator
end
def reset_data!
clear_data!
create_user!
Family.transaction do
clear_data!
create_user!
puts "user reset"
puts "user reset"
create_tags!
create_categories!
create_merchants!
create_tags!
create_categories!
create_merchants!
puts "tags, categories, merchants created"
puts "tags, categories, merchants created"
create_credit_card_account!
create_checking_account!
create_savings_account!
create_credit_card_account!
create_checking_account!
create_savings_account!
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
puts "accounts created"
family.sync
puts "balances synced"
puts "Demo data loaded successfully!"
puts "accounts created"
puts "Demo data loaded successfully!"
end
end
private
@@ -54,7 +52,10 @@ class Demo::Generator
end
def clear_data!
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
end
def create_user!
@@ -96,6 +97,7 @@ class Demo::Generator
accountable: CreditCard.new,
name: "Chase Credit Card",
balance: 2300,
currency: "USD",
institution: family.institutions.find_or_create_by(name: "Chase")
50.times do
@@ -122,6 +124,7 @@ class Demo::Generator
accountable: Depository.new,
name: "Chase Checking",
balance: 15000,
currency: "USD",
institution: family.institutions.find_or_create_by(name: "Chase")
10.times do
@@ -145,6 +148,7 @@ class Demo::Generator
accountable: Depository.new,
name: "Demo Savings",
balance: 40000,
currency: "USD",
subtype: "savings",
institution: family.institutions.find_or_create_by(name: "Chase")
@@ -161,23 +165,79 @@ class Demo::Generator
end
end
def load_securities!
# Create an unknown security to simulate edge cases
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock"
securities = [
{ ticker: "AAPL", name: "Apple Inc.", reference_price: 210 },
{ ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
{ ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
]
securities.each do |security_attributes|
security = Security.create! security_attributes.except(:reference_price)
# Load prices for last 2 years
(730.days.ago.to_date..Date.current).each do |date|
reference = security_attributes[:reference_price]
low_price = reference - 20
high_price = reference + 20
Security::Price.create! \
ticker: security.ticker,
date: date,
price: Faker::Number.positive(from: low_price, to: high_price)
end
end
end
def create_investment_account!
load_securities!
account = family.accounts.create! \
accountable: Investment.new,
name: "Robinhood",
balance: 100000,
currency: "USD",
institution: family.institutions.find_or_create_by(name: "Robinhood")
create_valuation!(account, 2.years.ago.to_date, 60000)
create_valuation!(account, 1.year.ago.to_date, 70000)
create_valuation!(account, 3.months.ago.to_date, 92000)
aapl = Security.find_by(ticker: "AAPL")
tm = Security.find_by(ticker: "TM")
msft = Security.find_by(ticker: "MSFT")
unknown = Security.find_by(ticker: "UNKNOWN")
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
trades = [
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
{ security: msft, qty: -5 }, { security: tm, qty: 10 }, { security: msft, qty: 5 },
{ security: tm, qty: 10 }, { security: aapl, qty: -5 }, { security: msft, qty: -5 },
{ security: tm, qty: 10 }, { security: msft, qty: 5 }, { security: aapl, qty: -10 }
]
trades.each do |trade|
date = Faker::Number.positive(to: 730).days.ago.to_date
security = trade[:security]
qty = trade[:qty]
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
name_prefix = qty < 0 ? "Sell " : "Buy "
account.entries.create! \
date: date,
amount: qty * price,
currency: "USD",
name: name_prefix + "#{qty} shares of #{security.ticker}",
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
end
end
def create_house_and_mortgage!
house = family.accounts.create! \
accountable: Property.new,
name: "123 Maybe Way",
balance: 560000
balance: 560000,
currency: "USD"
create_valuation!(house, 3.years.ago.to_date, 520000)
create_valuation!(house, 2.years.ago.to_date, 540000)
@@ -186,19 +246,22 @@ class Demo::Generator
family.accounts.create! \
accountable: Loan.new,
name: "Mortgage",
balance: 495000
balance: 495000,
currency: "USD"
end
def create_car_and_loan!
family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
balance: 18000
balance: 18000,
currency: "USD"
family.accounts.create! \
accountable: Loan.new,
name: "Car Loan",
balance: 8000
balance: 8000,
currency: "USD"
end
def create_transaction!(attributes = {})
@@ -262,6 +325,10 @@ class Demo::Generator
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
end
def securities
@securities ||= Security.all.to_a
end
def merchants
@merchants ||= family.merchants
end

View File

@@ -15,12 +15,11 @@ class ExchangeRate < ApplicationRecord
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = rates.map(&:date).to_set
all_dates = (start_date..end_date).to_a
existing_dates = rates.map(&:date)
missing_dates = all_dates - existing_dates
if missing_dates.any?
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
end
rates

View File

@@ -6,12 +6,31 @@ module ExchangeRate::Provided
class_methods do
private
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
return [] unless exchange_rates_provider.present?
dates.map do |date|
fetch_rate_from_provider from:, to:, date:, cache:
end.compact
response = exchange_rates_provider.fetch_exchange_rates \
from: from,
to: to,
start_date: start_date,
end_date: end_date
if response.success?
response.rates.map do |exchange_rate|
rate = ExchangeRate.new \
from_currency: from,
to_currency: to,
date: exchange_rate.dig(:date).to_date,
rate: exchange_rate.dig(:rate)
rate.save! if cache
rate
rescue ActiveRecord::RecordNotUnique
next
end
else
[]
end
end
def fetch_rate_from_provider(from:, to:, date:, cache: false)

View File

@@ -105,6 +105,16 @@ class Family < ApplicationRecord
end
def sync(start_date: nil)
accounts.active.sync(start_date: start_date)
accounts.active.each do |account|
if account.needs_sync?
account.sync_later(start_date: start_date || account.last_sync_date)
end
end
update! last_synced_at: Time.now
end
def needs_sync?
last_synced_at.nil? || last_synced_at.to_date < Date.current
end
end

View File

@@ -0,0 +1,54 @@
class Help::Article
attr_reader :frontmatter, :content
def initialize(frontmatter:, content:)
@frontmatter = frontmatter
@content = content
end
def title
frontmatter["title"]
end
def html
render_markdown(content)
end
class << self
def root_path
Rails.root.join("docs", "help")
end
def find(slug)
Dir.glob(File.join(root_path, "*.md")).each do |file_path|
file_content = File.read(file_path)
frontmatter, markdown_content = parse_frontmatter(file_content)
return new(frontmatter:, content: markdown_content) if frontmatter["slug"] == slug
end
nil
end
private
def parse_frontmatter(content)
if content =~ /\A---(.+?)---/m
frontmatter = YAML.safe_load($1)
markdown_content = content[($~.end(0))..-1].strip
else
frontmatter = {}
markdown_content = content
end
[ frontmatter, markdown_content ]
end
end
private
def render_markdown(content)
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML)
markdown.render(content)
end
end

View File

@@ -178,7 +178,8 @@ class Import < ApplicationRecord
begin
CSV.parse(raw_csv_str || "")
rescue CSV::MalformedCSVError
errors.add(:raw_csv_str, "is not a valid CSV format")
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_csv_str.invalid_csv_format')
errors.add(:raw_csv_str, :invalid_csv_format)
end
end
end

View File

@@ -13,4 +13,35 @@ class Investment < ApplicationRecord
[ "Roth 401k", "roth_401k" ],
[ "Angel", "angel" ]
].freeze
def value
account.balance_money + holdings_value
end
def holdings_value
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
end
def series(period: Period.all, currency: account.currency)
balance_series = account.balances.in_period(period).where(currency: currency)
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
holdings.sum(&:amount)
end
combined_series = balance_series.map do |balance|
holding_amount = holdings_by_date[balance.date] || 0
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
end
if combined_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
else
TimeSeries.new(combined_series)
end
rescue Money::ConversionError
TimeSeries.new([])
end
end

View File

@@ -5,10 +5,35 @@ class Provider::Synth
@api_key = api_key
end
def fetch_security_prices(ticker:, start_date:, end_date:)
prices = paginate(
"#{base_url}/tickers/#{ticker}/open-close",
start_date: start_date,
end_date: end_date
) do |body|
body.dig("prices").map do |price|
{
date: price.dig("date"),
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
currency: "USD"
}
end
end
SecurityPriceResponse.new \
prices: prices,
success?: true,
raw_response: prices.to_json
rescue StandardError => error
SecurityPriceResponse.new \
success?: false,
error: error,
raw_response: error
end
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
response = client.get("#{base_url}/rates/historical") do |req|
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
@@ -32,20 +57,100 @@ class Provider::Synth
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
exchange_rates = paginate(
"#{base_url}/rates/historical-range",
from: from,
to: to,
date_start: start_date.to_s,
date_end: end_date.to_s
) do |body|
body.dig("data").map do |exchange_rate|
{
date: exchange_rate.dig("date"),
rate: exchange_rate.dig("rates", to)
}
end
end
ExchangeRatesResponse.new \
rates: exchange_rates,
success?: true,
raw_response: exchange_rates.to_json
rescue StandardError => error
ExchangeRatesResponse.new \
success?: false,
error: error,
raw_response: error
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"
end
def app_name
"maybe_app"
end
def app_type
Rails.application.config.app_mode
end
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.headers["Authorization"] = "Bearer #{api_key}"
faraday.headers["X-Source"] = app_name
faraday.headers["X-Source-Type"] = app_type
end
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch exchange rate from #{self.class}
Failed to fetch data from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
def fetch_page(url, page, params = {})
client.get(url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["page"] = page
end
end
def paginate(url, params = {})
results = []
page = 1
current_page = 0
total_pages = 1
while current_page < total_pages
response = fetch_page(url, page, params)
if response.success?
body = JSON.parse(response.body)
page_results = yield(body)
results.concat(page_results)
current_page = body.dig("paging", "current_page")
total_pages = body.dig("paging", "total_pages")
page += 1
else
raise build_error(response)
end
end
results
end
end

13
app/models/security.rb Normal file
View File

@@ -0,0 +1,13 @@
class Security < ApplicationRecord
before_save :upcase_ticker
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
validates :ticker, presence: true, uniqueness: { case_sensitive: false }
private
def upcase_ticker
self.ticker = ticker.upcase
end
end

View File

@@ -0,0 +1,34 @@
class Security::Price < ApplicationRecord
include Provided
before_save :upcase_ticker
validates :ticker, presence: true, uniqueness: { scope: :date, case_sensitive: false }
class << self
def find_price(ticker:, date:, cache: true)
result = find_by(ticker:, date:)
result || fetch_price_from_provider(ticker:, date:, cache:)
end
def find_prices(ticker:, start_date:, end_date: Date.current, cache: true)
prices = where(ticker:, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = prices.map(&:date).to_set
missing_dates = (all_dates - existing_dates).sort
if missing_dates.any?
prices += fetch_prices_from_provider(ticker:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
end
prices
end
end
private
def upcase_ticker
self.ticker = ticker.upcase
end
end

View File

@@ -0,0 +1,55 @@
module Security::Price::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_price_from_provider(ticker:, date:, cache: false)
return nil unless security_prices_provider.present?
response = security_prices_provider.fetch_security_prices \
ticker: ticker,
start_date: date,
end_date: date
if response.success? && response.prices.size > 0
price = Security::Price.new \
ticker: ticker,
date: response.prices.first[:date],
price: response.prices.first[:price],
currency: response.prices.first[:currency]
price.save! if cache
price
else
nil
end
end
def fetch_prices_from_provider(ticker:, start_date:, end_date:, cache: false)
return [] unless security_prices_provider.present?
response = security_prices_provider.fetch_security_prices \
ticker: ticker,
start_date: start_date,
end_date: end_date
if response.success?
response.prices.map do |price|
new_price = Security::Price.new \
ticker: ticker,
date: price[:date],
price: price[:price],
currency: price[:currency]
new_price.save! if cache
new_price
end
else
[]
end
end
end
end

View File

@@ -44,7 +44,7 @@ class TimeSeries::Trend
end
def percent
if previous.nil?
if previous.nil? || (previous.zero? && current.zero?)
0.0
elsif previous.zero?
Float::INFINITY
@@ -83,18 +83,22 @@ class TimeSeries::Trend
def values_must_be_of_same_type
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
errors.add :current, "must be of the same type as previous"
errors.add :previous, "must be of the same type as current"
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.current.must_be_of_the_same_type_as_previous')
errors.add :current, :must_be_of_the_same_type_as_previous
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.previous.must_be_of_the_same_type_as_current')
errors.add :previous, :must_be_of_the_same_type_as_current
end
end
def values_must_be_of_known_type
unless current.is_a?(Money) || current.is_a?(Numeric) || current.nil?
errors.add :current, "must be of type Money, Numeric, or nil"
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.current.must_be_of_type_money_numeric_or_nil')
errors.add :current, :must_be_of_type_money_numeric_or_nil
end
unless previous.is_a?(Money) || previous.is_a?(Numeric) || previous.nil?
errors.add :previous, "must be of type Money, Numeric, or nil"
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.previous.must_be_of_type_money_numeric_or_nil')
errors.add :previous, :must_be_of_type_money_numeric_or_nil
end
end

View File

@@ -40,7 +40,8 @@ class TimeSeries::Value
def value_must_be_of_known_type
unless value.is_a?(Money) || value.is_a?(Numeric)
errors.add :value, "must be a Money or Numeric"
# i18n-tasks-use t('activemodel.errors.models.time_series/value.attributes.value.must_be_a_money_or_numeric')
errors.add :value, :must_be_a_money_or_numeric
end
end
end

View File

@@ -55,7 +55,8 @@ class User < ApplicationRecord
def can_deactivate
if admin? && family.users.count > 1
errors.add(:base, I18n.t("activerecord.errors.user.cannot_deactivate_admin_with_other_users"))
# i18n-tasks-use t('activerecord.errors.models.user.attributes.base.cannot_deactivate_admin_with_other_users')
errors.add(:base, :cannot_deactivate_admin_with_other_users)
end
end
@@ -83,7 +84,8 @@ class User < ApplicationRecord
def profile_image_size
if profile_image.attached? && profile_image.byte_size > 5.megabytes
errors.add(:profile_image, "is too large. Maximum size is 5 MB.")
# i18n-tasks-use t('activerecord.errors.models.user.attributes.profile_image.invalid_file_size')
errors.add(:profile_image, :invalid_file_size, max_megabytes: 5)
end
end
end

View File

@@ -0,0 +1,21 @@
<%# locals: (holding:) %>
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-9 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= tag.p holding.name %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-3 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,18 @@
<%= turbo_frame_tag dom_id(@account, "cash") do %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".cash"), class: "font-medium text-lg" %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".name"), class: "col-span-9" %>
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
</div>
</div>
</div>
<% end %>

View File

@@ -1,4 +1,5 @@
<%# locals: (entry:, **opts) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry: entry, **opts } %>
<% end %>

View File

@@ -1,4 +1,4 @@
<%# locals: (date:, entries:, selectable: true, **opts) %>
<%# locals: (date:, entries:, content:, selectable:) %>
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
@@ -15,7 +15,6 @@
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
<%= render transfer_entries(entries), selectable:, **opts %>
<%= content %>
</div>
</div>

View File

@@ -1,3 +0,0 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %>
<% end %>

View File

@@ -1 +0,0 @@
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>

View File

@@ -1,2 +0,0 @@
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>

View File

@@ -1 +0,0 @@
<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %>

View File

@@ -1,3 +0,0 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %>
<% end %>

View File

@@ -1 +0,0 @@
<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %>

View File

@@ -0,0 +1,45 @@
<%# locals: (holding:) %>
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name || "H" %>
<div>
<%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-2 flex justify-end items-center gap-2">
<% if holding.weight %>
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
<div class="col-span-2 text-right">
<%= tag.p format_money holding.avg_cost %>
<%= tag.p t(".per_share"), class: "font-normal text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<% if holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1 @@
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>

View File

@@ -0,0 +1,37 @@
<%= turbo_frame_tag dom_id(@account, "holdings") do %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_trade_path(@account),
id: dom_id(@account, "new_trade"),
data: { turbo_frame: :modal },
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_holding"), class: "text-sm" %>
<% end %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".name"), class: "col-span-4" %>
<%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".cost"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".holdings"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
<% elsif @account.needs_sync? || true %>
<div class="flex flex-col justify-center items-center pt-4 pb-8">
<p class="text-gray-500 p-4"><%= t(".needs_sync") %></p>
<%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
</div>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1 @@
<p>Coming soon...</p>

View File

@@ -0,0 +1,45 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %>
<%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
</div>
<%= render "shared/circle_logo", name: @holding.name %>
</header>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".history") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
</div>
<% end %>

View File

@@ -0,0 +1,34 @@
<%# locals: (entry:) %>
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
scope: :account_entry,
url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %>
<div class="space-y-4">
<div class="space-y-2">
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
<div data-trade-form-target="tickerInput">
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
</div>
<%= form.date_field :date, label: true %>
<div data-trade-form-target="amountInput" hidden>
<%= money_with_currency_field form, :amount_money, label: t(".amount"), disable_currency: true %>
</div>
<div data-trade-form-target="transferAccountInput" hidden>
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
</div>
<div data-trade-form-target="qtyInput">
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
</div>
<div data-trade-form-target="priceInput">
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
</div>
</div>
<%= form.submit t(".submit") %>
</div>
<% end %>

View File

@@ -0,0 +1,15 @@
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
<div class="flex items-center gap-2">
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<p data-bulk-select-target="selectionBarText"></p>
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<%# locals: (entry:, selectable: true, **opts) %>
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="pr-10 flex items-center gap-4 col-span-6">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="max-w-full">
<%= tag.div class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry_name(entry).first.upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry_name(entry) %>
<% else %>
<%= link_to entry_name(entry),
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<% if entry.account_transaction? && entry.marked_as_transfer? %>
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
<% elsif entry.account_transaction? %>
<%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %>
<% else %>
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
<% end %>
</div>
<div class="col-span-3 flex items-center justify-end">
<% if entry.account_transaction? %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %>
<% else %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,42 @@
<%= turbo_frame_tag dom_id(@account, "trades") do %>
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex justify-between items-center">
<h3 class="font-medium text-lg"><%= t(".trades") %></h3>
<%= link_to new_account_trade_path(@account),
id: dom_id(@account, "new_trade"),
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<span class="text-sm"><%= t(".new") %></span>
<% end %>
</div>
<div class="bg-gray-25 rounded-xl grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-5 py-3">
<div class="pl-0.5 col-span-6 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<%= tag.p t(".trade") %>
</div>
<%= tag.p t(".type"), class: "col-span-3 justify-self-end" %>
<%= tag.p t(".amount"), class: "col-span-3 justify-self-end" %>
</div>
<div>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "selection_bar" %>
</div>
<% if @entries.empty? %>
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
<% else %>
<div class="space-y-6">
<%= entries_by_date(@entries) do |entries| %>
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,3 @@
<%= modal_form_wrapper title: t(".title") do %>
<%= render "account/trades/form", entry: @entry %>
<% end %>

View File

@@ -0,0 +1,29 @@
<% entry = @entry %>
<%= drawer do %>
<div>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
<span class="text-lg text-gray-500"><%= entry.currency %></span>
</h3>
</div>
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 pl-4 text-gray-500">
<p>Details coming soon...</p>
</div>
</details>
</div>
</div>
<% end %>

View File

@@ -9,7 +9,6 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= form_with url: mark_transfers_transactions_path,
builder: ActionView::Helpers::FormBuilder,
scope: "bulk_update",
data: {
turbo_frame: "_top",
@@ -36,7 +35,7 @@
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
<% end %>
<%= form_with url: bulk_delete_transactions_path, builder: ActionView::Helpers::FormBuilder, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>

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