Compare commits

...

141 Commits

Author SHA1 Message Date
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
Zach Gollwitzer
744ffb68aa Bump to v0.1.0-alpha.10
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-12 18:38:17 -04:00
Zach Gollwitzer
34e03c2d6a Make balance editing easier (#976)
* Make balance editing easier

* Translations

* Fix money input option

* Fix balance sync logic

* Rework balance update flow
2024-07-12 13:47:39 -04:00
Zach Gollwitzer
b002a41b35 New demo user data (#972) 2024-07-11 08:37:21 -04:00
Zach Gollwitzer
c6bdf49f10 Account::Sync model and test fixture simplifications (#968)
* Add sync model

* Fresh fixtures for sync tests

* Sync tests overhaul

* Fix entry tests

* Complete remaining model test updates

* Update system tests

* Update demo data task

* Add system tests back to PR checks

* More simplifications, add empty family to fixtures for easier testing
2024-07-10 11:22:59 -04:00
Tony Vincent
de5a2e55b3 Add missing migrations for good_job 4x (#967)
* Goodjob 3.99

* Properly bump to good_job 4.0.0

* Remove accidently tracked .tool-versions
2024-07-09 14:23:19 -04:00
dependabot[bot]
538b00712c Bump rails from df02832 to 8075866 (#962)
Bumps [rails](https://github.com/rails/rails) from `df02832` to `8075866`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](df02832784...8075866ae8)

---
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-08 13:35:30 -04:00
dependabot[bot]
2e56f5726e Bump sentry-rails from 5.18.0 to 5.18.1 (#964)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.18.0 to 5.18.1.
- [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.0...5.18.1)

---
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-08 09:15:11 -04:00
dependabot[bot]
3c9cdb16f9 Bump pagy from 8.6.1 to 8.6.3 (#963)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.6.1 to 8.6.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/8.6.1...8.6.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-08 09:14:46 -04:00
dependabot[bot]
6d4c871f85 Bump ruby-lsp-rails from 0.3.7 to 0.3.8 (#960)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.7 to 0.3.8.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.7...v0.3.8)

---
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-08 09:14:31 -04:00
dependabot[bot]
dd915c42ed Bump aws-sdk-s3 from 1.155.0 to 1.156.0 (#961)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.155.0 to 1.156.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-07-08 09:07:52 -04:00
dependabot[bot]
0447d47a53 Bump good_job from 3.29.5 to 4.0.0 (#959)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.5 to 4.0.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/v3.29.5...v4.0.0)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:07:12 -04:00
dependabot[bot]
42dec4014e Bump sentry-ruby from 5.18.0 to 5.18.1 (#958)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.18.0 to 5.18.1.
- [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.0...5.18.1)

---
updated-dependencies:
- dependency-name: sentry-ruby
  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-08 09:05:30 -04:00
Zach Gollwitzer
6767aaed1d Handle missing exchange rate provider, allow fallback for missing rates (#955)
* Clean up exchange rate logic

* Remove stale method
2024-07-08 09:04:59 -04:00
Magnus Jensen
bef335c631 fix: #951 pointer cursor and bg hover for import flow buttons (#954)
* set cursor to pointer on buttons in imports flow

* add hover bg change on buttons to indicate action

* add rounded hover background to x
2024-07-08 08:56:08 -04:00
Tony Vincent
3ffb6cb62b Add error handling for AccountsController#create (#957) 2024-07-08 08:53:45 -04:00
Zach Gollwitzer
cea90252c8 Bump to v0.1.0-alpha.9
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-05 14:16:41 -04:00
Zach Gollwitzer
36cccefb2a Update docker compose example with fixed storage volume (#950) 2024-07-05 14:16:02 -04:00
Tony Vincent
cc6bf6e961 Enable syncing all accounts in one click (#948)
* Enable syncing all accounts on one click

* Remove argument to sync_later method call

* Add partial for sync all accounts button

* Redirect back if possible when syncing all accounts
2024-07-05 07:36:18 -04:00
Tony Vincent
48092cb704 Enque account sync job after creating transfer (#946) 2024-07-04 06:57:26 -04:00
Luke Hurst
cf23453d93 Fix bug where transactions were duplicated in import confirm (#941)
* Fix bug where transactions were duplicated in import confirm

Signed-off-by: Luke Hurst <hurstlj@umich.edu>

* Update imports_test.rb

Signed-off-by: Luke Hurst <hurstlj@umich.edu>

---------

Signed-off-by: Luke Hurst <hurstlj@umich.edu>
2024-07-03 17:44:35 -04:00
Tony Vincent
f1d0a62ac7 Enable updating Account::Entry#amount (#942)
- Enable updating transaction type income/expense
- Enable updating transaction amount
2024-07-03 15:54:05 -04:00
Zach Gollwitzer
3089e3c81d Remove custom currency styling, use default formatter (#937) 2024-07-02 07:27:19 -04:00
dependabot[bot]
0593d8fb7e Bump rails from 9e370f0 to df02832 (#930)
Bumps [rails](https://github.com/rails/rails) from `9e370f0` to `df02832`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](9e370f0243...df02832784)

---
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-01 11:24:51 -04:00
dependabot[bot]
a8ea207d47 Bump good_job from 3.29.4 to 3.29.5 (#929)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.4 to 3.29.5.
- [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/v3.29.4...v3.29.5)

---
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-01 11:21:09 -04:00
dependabot[bot]
c225eb6d03 Bump aws-sdk-s3 from 1.152.3 to 1.155.0 (#928)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.152.3 to 1.155.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-07-01 11:20:33 -04:00
dependabot[bot]
9b148316bc Bump pagy from 8.4.5 to 8.6.1 (#932)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.5 to 8.6.1.
- [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/8.4.5...8.6.1)

---
updated-dependencies:
- dependency-name: pagy
  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-01 11:19:49 -04:00
dependabot[bot]
8e7fcfd0b4 Bump sentry-ruby from 5.17.3 to 5.18.0 (#933)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.17.3 to 5.18.0.
- [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.17.3...5.18.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  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-01 11:19:21 -04:00
Zach Gollwitzer
c3314e62d1 Account::Entry Delegated Type (namespace updates part 7) (#923)
* Initial entryable models

* Update transfer and tests

* Update transaction controllers and tests

* Update sync process to use new entries model

* Get dashboard working again

* Update transfers, imports, and accounts to use Account::Entry

* Update system tests

* Consolidate transaction management into entries controller

* Add permitted partial key helper

* Move account transactions list to entries controller

* Delegate transaction entries search

* Move transfer relation to entry

* Update bulk transaction management flows to use entries

* Remove test code

* Test fix attempt

* Update demo data script

* Consolidate remaining transaction partials to entries

* Consolidate valuations controller to entries controller

* Lint fix

* Remove unused files, additional cleanup

* Add back valuation creation

* Make migrations fully reversible

* Stale routes cleanup

* Migrations reversible fix

* Move types to entryable concern

* Fix search when no entries found

* Remove more unused code
2024-07-01 10:49:43 -04:00
Zach Gollwitzer
320954282a Bump to v0.1.0-alpha.8
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-06-28 17:22:26 -04:00
Tony Vincent
9e1d8a753b Fix #921 (#922)
* Fix #921

* Fix linter errors

* Fix test failure

* Remove unused keys

* Add back html rendering

* Remove .tool-versions from repository

* Fix failing test
2024-06-26 14:56:34 -04:00
evangelos-com
3d4def59d6 improvement/#890/clean_up_toast_notification_styles_and_allow_user_to_close_on-demand (#919)
* initial improvement

* Update app/views/shared/_notification.html.erb

Signed-off-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>

---------

Signed-off-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-06-25 15:03:34 -04:00
Zach Gollwitzer
da18c3d850 Account namespace updates: part 6 (transactions) (#904)
* Move Transaction to Account namespace

* Fix improper routes, improve separation of concerns

* Replace account transactions list partial with view

* Remove logs

* Consolidate transaction views

* Remove unused code

* Transfer style tweaks

* Remove more unused code

* Add back totals by currency helper
2024-06-24 11:58:39 -04:00
dependabot[bot]
cb3fd34f90 Bump docker/build-push-action from 5 to 6 (#916)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 10:58:38 -04:00
dependabot[bot]
593892bc2b Bump faraday from 2.9.1 to 2.9.2 (#914)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.9.1 to 2.9.2.
- [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.1...v2.9.2)

---
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-06-24 10:56:56 -04:00
Tony Vincent
bbcd3881db Fix #910 (#917)
* Fix #910

* Unify helper for balance formatting in transactions and accounts views

* Remove obsolete method

* Rename helper method format_amount_by_curreny => totals_by_currency
2024-06-24 10:56:44 -04:00
dependabot[bot]
ee53546c1b Bump good_job from 3.29.3 to 3.29.4 (#912)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.3 to 3.29.4.
- [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/v3.29.3...v3.29.4)

---
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-06-24 10:32:26 -04:00
dependabot[bot]
66c27b8df4 Bump pagy from 8.4.4 to 8.4.5 (#911)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.4 to 8.4.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/8.4.4...8.4.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-06-24 10:32:16 -04:00
dependabot[bot]
03e027e089 Bump selenium-webdriver from 4.21.1 to 4.22.0 (#915)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.21.1 to 4.22.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/commits/selenium-4.22.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-06-24 10:32:05 -04:00
dependabot[bot]
b7799aaa8e Bump rails from 5d34172 to 9e370f0 (#913)
Bumps [rails](https://github.com/rails/rails) from `5d34172` to `9e370f0`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](5d34172ff4...9e370f0243)

---
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-06-24 10:31:43 -04:00
Igor Carvalho
094128fef1 Fix issue #861: Correct header selection logic in get_selected_header_for_field method (#918)
The get_selected_header_for_field method was incorrectly using the entire field object instead of the field key to dig into the column_mappings hash. This caused an error when trying to retrieve the selected header for a field.
2024-06-24 10:31:21 -04:00
Jakub Kottnauer
a5212f0f5e Unify submit button styles and change cursor on account group (#905) 2024-06-24 06:49:08 -04:00
Zach Gollwitzer
62d5df795b Bump to v0.1.0-alpha.7
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-06-21 17:04:59 -04:00
Jakub Kottnauer
3cae528dfd Allow transfers based on transactions in different currencies (#903)
* Allow transfers between transactions in different currencies

* Review fixes
2024-06-21 17:04:15 -04:00
Zach Gollwitzer
12380dc8ad Account namespace updates: part 5 (valuations) (#901)
* Move Valuation to Account namespace

* Move account history to controller

* Clean up valuation controller and views

* Translations and cleanup

* Remove unused scopes and methods

* Pass brakeman
2024-06-21 16:23:28 -04:00
Jakub Kottnauer
0bc0d87768 Fix transfer note overflow style (#902) 2024-06-21 15:57:55 -04:00
Karan Kiri
e13c3d9271 feat: Transaction pagination Improvements (#873)
* feat: make transaction container fixed height

* feat: pagination per page query

* fix: linting errors

* Changelog page that pulls from Github Release notes (#867)

* Changelog page that pulls from Github Release notes

* Review changelog page styles

* Move changelog page title to i18n translations

* Bump to 0.1.0-alpha.6

Signed-off-by: Zach Gollwitzer <zach@maybe.co>

* Bump aws-sdk-s3 from 1.152.0 to 1.152.3 (#880)

Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.152.0 to 1.152.3.
- [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-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump mocha from 2.3.0 to 2.4.0 (#878)

Bumps [mocha](https://github.com/freerange/mocha) from 2.3.0 to 2.4.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: mocha
  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>

* Bump octokit from 8.1.0 to 9.1.0 (#877)

Bumps [octokit](https://github.com/octokit/octokit.rb) from 8.1.0 to 9.1.0.
- [Release notes](https://github.com/octokit/octokit.rb/releases)
- [Changelog](https://github.com/octokit/octokit.rb/blob/main/RELEASE.md)
- [Commits](https://github.com/octokit/octokit.rb/compare/v8.1.0...v9.1.0)

---
updated-dependencies:
- dependency-name: octokit
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump rails from `f9c847f` to `5d34172` (#879)

Bumps [rails](https://github.com/rails/rails) from `f9c847f` to `5d34172`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](f9c847fac1...5d34172ff4)

---
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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>

* Update issue templates

* Add merchant select when editing transaction (#885)

* Transaction transfers, payments, and matching (#883)

* Add transfer model and clean up family snapshot fixtures

* Ignore transfers in income and expense snapshots

* Add transfer validations

* Implement basic transfer matching UI

* Fix merge conflicts

* Add missing translations

* Tweak selection states for transfer types

* Add missing i18n translation

* Ensure correct form's hidden input for selectedIds (#891)

* feat: make transaction container fixed height

* feat: pagination per page query

* fix: linting errors

* Transaction transfers, payments, and matching (#883)

* Add transfer model and clean up family snapshot fixtures

* Ignore transfers in income and expense snapshots

* Add transfer validations

* Implement basic transfer matching UI

* Fix merge conflicts

* Add missing translations

* Tweak selection states for transfer types

* Add missing i18n translation

* feat: make transaction container fixed height

* feat: pagination per page query

* fix: linting errors

* revert unnecessary changes

* revert unnecessary changes

* code review changes

* code review changes

* code review changes

* remove unused imports

* fix: unit tests

* remove border

* fix: transaction padding

* fix: transaction container height

---------

Signed-off-by: Zach Gollwitzer <zach@maybe.co>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Karan Kiri <karankiri.96@gmail.com>
Co-authored-by: Mattia <malnis.mattia@gmail.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Co-authored-by: Jakub Kottnauer <jk@jakubkottnauer.com>
Co-authored-by: ziraq young <ziraqyoung@outlook.com>
2024-06-21 12:04:40 -04:00
Tony Vincent
1e0635b31a Closes maybe-finance/maybe#843 (#900)
Co-authored-by: Tony Yesudas <tony3vincent@icloud.com>
2024-06-21 10:51:36 -04:00
Zach Gollwitzer
bddaab0192 Account namespace updates: part 4 (transfers, singular namespacing) (#896)
* Move Transfer to Account namespace

* Fix partial resolution due to namespacing plurality

* Make category and tag controllers consistent with namespacing convention

* Update stale partial reference
2024-06-20 13:32:44 -04:00
Zach Gollwitzer
dc3147c101 Move merchants to top-level namespace (#895) 2024-06-20 08:38:59 -04:00
Zach Gollwitzer
2681dd96b1 Move categories to top-level namespace (#894) 2024-06-20 08:15:09 -04:00
Zach Gollwitzer
a947db92b2 Account namespace updates: part 1 (#893)
* Rename accountable types

* Merge conflicts

* Fix broken tests

* Add back sidebar changes
2024-06-20 07:26:25 -04:00
ziraq young
778098ebb0 Ensure correct form's hidden input for selectedIds (#891) 2024-06-19 16:50:32 -04:00
Zach Gollwitzer
ca39b26070 Transaction transfers, payments, and matching (#883)
* Add transfer model and clean up family snapshot fixtures

* Ignore transfers in income and expense snapshots

* Add transfer validations

* Implement basic transfer matching UI

* Fix merge conflicts

* Add missing translations

* Tweak selection states for transfer types

* Add missing i18n translation
2024-06-19 06:52:08 -04:00
Jakub Kottnauer
b462bc8f8c Add merchant select when editing transaction (#885) 2024-06-18 08:54:25 -04:00
Zach Gollwitzer
73ecf0b912 Update issue templates 2024-06-17 12:12:41 -04:00
dependabot[bot]
cdaed495b3 Bump rails from f9c847f to 5d34172 (#879)
Bumps [rails](https://github.com/rails/rails) from `f9c847f` to `5d34172`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](f9c847fac1...5d34172ff4)

---
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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-06-17 12:04:16 -04:00
dependabot[bot]
651028a9f3 Bump octokit from 8.1.0 to 9.1.0 (#877)
Bumps [octokit](https://github.com/octokit/octokit.rb) from 8.1.0 to 9.1.0.
- [Release notes](https://github.com/octokit/octokit.rb/releases)
- [Changelog](https://github.com/octokit/octokit.rb/blob/main/RELEASE.md)
- [Commits](https://github.com/octokit/octokit.rb/compare/v8.1.0...v9.1.0)

---
updated-dependencies:
- dependency-name: octokit
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 11:59:13 -04:00
dependabot[bot]
c4fb9a54a2 Bump mocha from 2.3.0 to 2.4.0 (#878)
Bumps [mocha](https://github.com/freerange/mocha) from 2.3.0 to 2.4.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: mocha
  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-06-17 11:56:42 -04:00
dependabot[bot]
9af355fc59 Bump aws-sdk-s3 from 1.152.0 to 1.152.3 (#880)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.152.0 to 1.152.3.
- [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-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 11:55:10 -04:00
Zach Gollwitzer
773cd0da71 Bump to 0.1.0-alpha.6
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-06-14 16:50:08 -04:00
Mattia
5da34c4609 Changelog page that pulls from Github Release notes (#867)
* Changelog page that pulls from Github Release notes

* Review changelog page styles

* Move changelog page title to i18n translations
2024-06-14 16:40:50 -04:00
Zach Gollwitzer
957584b69c Clean up sync logic (#871) 2024-06-13 17:03:38 -04:00
Zach Gollwitzer
d0a15b8a98 Improve self hosting docs and UI (#870) 2024-06-13 16:19:05 -04:00
Zach Gollwitzer
9956a9540e Add institution management and account editing controls (#868)
* Add institution management

* Allow user to select institution on create or edit

* Improve redirect behavior

* Final cleanup

* i18n normalization
2024-06-13 14:37:27 -04:00
Zach Gollwitzer
8c1a7af37f Allow for optional start date on account creation (#866) 2024-06-13 09:16:00 -04:00
Zach Gollwitzer
c5704ffd45 Improve account internal linking and redirect behavior (#864)
* Fix transaction row link and overflow

* Allow user to access imports from account page

* Clean up accounts controller, add link to account page from settings

* Add link to accounts management from accounts summary page

* Cleanup styles
2024-06-11 18:47:38 -04:00
Zach Gollwitzer
8372e26864 Allow optional import fields (#865) 2024-06-11 18:46:44 -04:00
dependabot[bot]
6477c0f766 Bump aws-sdk-s3 from 1.151.0 to 1.152.0 (#854)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.151.0 to 1.152.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-06-10 12:39:15 -04:00
dependabot[bot]
2a8bb57c9c Bump pagy from 8.4.1 to 8.4.4 (#853)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.1 to 8.4.4.
- [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/8.4.1...8.4.4)

---
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-06-10 12:38:44 -04:00
dependabot[bot]
2f432ec0c3 Bump good_job from 3.29.2 to 3.29.3 (#851)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.2 to 3.29.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/v3.29.2...v3.29.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-06-10 12:38:25 -04:00
dependabot[bot]
e3269e8981 Bump faraday from 2.9.0 to 2.9.1 (#850)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.9.0 to 2.9.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.9.0...v2.9.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-06-10 12:37:59 -04:00
dependabot[bot]
8f891b8d8c Bump tailwindcss-rails from 2.6.0 to 2.6.1 (#848)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.0 to 2.6.1.
- [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.0...v2.6.1)

---
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-06-10 12:37:04 -04:00
dependabot[bot]
775921092c Bump rails from 8e7eb03 to f9c847f (#855)
Bumps [rails](https://github.com/rails/rails) from `8e7eb03` to `f9c847f`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](8e7eb03d99...f9c847fac1)

---
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-06-10 12:36:08 -04:00
dependabot[bot]
83e2bfceb8 Bump lucide-rails from 6170b3a to 79d9895 (#849)
Bumps [lucide-rails](https://github.com/maybe-finance/lucide-rails) from `6170b3a` to `79d9895`.
- [Commits](6170b3a0ec...79d989593e)

---
updated-dependencies:
- dependency-name: lucide-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-06-10 12:32:25 -04:00
Zach Gollwitzer
87a40aafeb Bump to v0.1.0-alpha.5
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-06-07 19:29:01 -04:00
Zach Gollwitzer
a681e73fea Enable bulk editing of transactions (#846) 2024-06-07 18:59:46 -04:00
Zach Gollwitzer
d3f9be15f1 Bulk transaction deletion (#845)
* Clean up transaction show view, add delete button

* Clean up tailwind global styles, add switch

* Bulk deletion controller and tests

* Normalize translations

* Add bulk deletion button and form
2024-06-07 16:56:30 -04:00
Zach Gollwitzer
115f792198 Add bulk selection UI controls (#840)
* Add bulk selection UI

* Handle bulk selection with Stimulus controller instead of session

* Update tests

* Remove stale routes

* Remove old system test helper methods
2024-06-07 12:44:06 -04:00
dependabot[bot]
e4ac5c87e4 Bump rails from c1f1b14 to 8e7eb03 (#828)
Bumps [rails](https://github.com/rails/rails) from `c1f1b14` to `8e7eb03`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](c1f1b14adc...8e7eb03d99)

---
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-06-03 08:41:06 -04:00
dependabot[bot]
a4fef176e8 Bump pagy from 8.4.0 to 8.4.1 (#825)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.0 to 8.4.1.
- [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/8.4.0...8.4.1)

---
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-06-03 08:33:46 -04:00
dependabot[bot]
ee5fc2be38 Bump ruby-lsp-rails from 0.3.6 to 0.3.7 (#826)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.6 to 0.3.7.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.6...v0.3.7)

---
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-06-03 08:33:35 -04:00
Zach Gollwitzer
28524b3f08 Bump to v0.1.0-alpha.4 (#822) 2024-05-31 14:09:12 -04:00
Zach Gollwitzer
bcbb37a146 Client-side validation for Decimal precision of 19,4 (#821) 2024-05-30 22:07:47 -04:00
Zach Gollwitzer
de53a50e45 Sync account after transaction import (#820) 2024-05-30 22:06:32 -04:00
Zach Gollwitzer
32e647f0fb Support 32 and 64 bit ARM architectures for Docker image
Fixes #816

Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-05-30 21:42:09 -04:00
Zach Gollwitzer
4ebc08e5a4 Transactions cleanup (#817)
An overhaul and cleanup of the transactions feature including:

- Simplification of transactions search and filtering
- Consolidation of account sync logic after transaction change
- Split sidebar modal and modal into "drawer" and "modal" concepts
- Refactor of transaction partials and folder organization
- Cleanup turbo frames and streams for transaction updates, including new Transactions::RowsController for inline updates
- Refactored and added several integration and systems tests
2024-05-30 20:55:18 -04:00
Zach Gollwitzer
ee162bbef7 Reuse ci workflow (#819) 2024-05-30 15:44:16 -04:00
Zach Gollwitzer
df391e0a14 Update issue templates 2024-05-28 13:23:15 -04:00
Jakub Kottnauer
6182a62573 Sort accounts in the sidebar (#815) 2024-05-28 13:22:04 -04:00
dependabot[bot]
981a1cb2ee Bump rails from ed50b93 to c1f1b14 (#814)
Bumps [rails](https://github.com/rails/rails) from `ed50b93` to `c1f1b14`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](ed50b93ebc...c1f1b14adc)

---
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-05-27 12:11:53 -04:00
dependabot[bot]
e0d8499a8c Bump propshaft from 0.8.0 to 0.9.0 (#812)
Bumps [propshaft](https://github.com/rails/propshaft) from 0.8.0 to 0.9.0.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v0.8.0...v0.9.0)

---
updated-dependencies:
- dependency-name: propshaft
  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-05-27 12:10:42 -04:00
Jakub Kottnauer
483d67846c Fix foreign account sync crash (#794)
* Fix foreign account sync crash

* Refactor synth provider and show UI error if not configured

* Generate error message on missing exchange rates while converting balances

* Ignore sync messaged in i18n-tasks unused

* Generate missing exchange rate error during entry normalization

* Update alert classes
2024-05-27 12:10:28 -04:00
dependabot[bot]
e9c8897eaf Bump webmock from 3.23.0 to 3.23.1 (#813)
Bumps [webmock](https://github.com/bblimke/webmock) from 3.23.0 to 3.23.1.
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.23.0...v3.23.1)

---
updated-dependencies:
- dependency-name: webmock
  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-05-27 11:28:46 -04:00
dependabot[bot]
9e09931c0e Bump good_job from 3.28.3 to 3.29.2 (#811)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.28.3 to 3.29.2.
- [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/v3.28.3...v3.29.2)

---
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-05-27 10:32:54 -04:00
Jakub Kottnauer
98f3f172a9 Validate transaction filtering params (#810) 2024-05-27 10:01:08 -04:00
pea-sys
0e15bda6eb fix: png file can be selected as profile images (#809)
Signed-off-by: pea-sys <49807271+pea-sys@users.noreply.github.com>
2024-05-26 08:55:31 -05:00
461 changed files with 9267 additions and 4774 deletions

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

View File

@@ -2,16 +2,11 @@
name: Bug report
about: Create a report to help us improve
title: 'Bug: '
labels: ":bug: Bug, :rocket: Feature"
labels: ":bug: Bug"
assignees: ''
---
**Where did this bug occur?**
- [ ] Local development
- [ ] Self hosted app (i.e. Docker)
**Describe the bug**
A clear and concise description of what the bug is.

View File

@@ -1,7 +1,7 @@
name: CI
on:
pull_request:
workflow_call:
jobs:
scan_ruby:
@@ -59,6 +59,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432
RAILS_ENV: test
services:
postgres:
image: postgres
@@ -82,16 +86,18 @@ jobs:
ruby-version: .ruby-version
bundler-cache: true
- name: Run tests and smoke test seed
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
- name: DB setup and smoke test
run: |
bin/rails db:create
bin/rails db:schema:load
bin/rails test
bin/rails db:seed
- name: Unit and integration tests
run: bin/rails test
- name: System tests
run: DISABLE_PARALLELIZATION=true bin/rails test:system
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()

8
.github/workflows/pr.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
name: Pull Request
on:
pull_request:
jobs:
ci:
uses: ./.github/workflows/ci.yml

View File

@@ -15,56 +15,12 @@ permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run tests and smoke test seed
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
run: |
bin/rails db:create
bin/rails db:schema:load
bin/rails test:all
bin/rails db:seed
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: ${{ github.workspace }}/tmp/screenshots
if-no-files-found: ignore
ci:
uses: ./.github/workflows/ci.yml
build:
name: Build docker image
needs: [ test ]
needs: [ ci ]
runs-on: ubuntu-latest
@@ -102,14 +58,14 @@ jobs:
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
id: build
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64/v8
platforms: linux/amd64,linux/arm64,linux/arm/v7
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: 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

@@ -25,9 +25,6 @@ gem "turbo-rails"
# Background Jobs
gem "good_job"
# Search
gem "ransack", github: "maybe-finance/ransack", branch: "main"
# Error logging
gem "stackprof"
gem "sentry-ruby"
@@ -47,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 ]
@@ -54,14 +52,15 @@ 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"
gem "web-console"
gem "faker"
end
group :test do

View File

@@ -1,48 +1,38 @@
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 6170b3a0eceb43a8af6552638e9526673c356d0d
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87
specs:
lucide-rails (0.2.0)
railties (>= 4.1.0)
GIT
remote: https://github.com/maybe-finance/ransack.git
revision: dec20edc9ccccac77f5b4b8a1c1a9f20dc58fa04
branch: main
specs:
ransack (4.1.1)
activerecord (>= 6.1.5)
activesupport (>= 6.1.5)
i18n
GIT
remote: https://github.com/rails/rails.git
revision: ed50b93ebcf3c1a92ac3481297c07c33d9f7c161
revision: 5cb5cad3224d03114313fbe28c4dd1374c313d8f
branch: 7-2-stable
specs:
actioncable (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actioncable (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activestorage (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionmailbox (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
mail (>= 2.8.0)
actionmailer (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
actionview (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionmailer (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
actionview (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0.alpha)
actionview (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionpack (7.2.0.beta3)
actionview (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@@ -51,60 +41,61 @@ GIT
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activestorage (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actiontext (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionview (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activejob (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
globalid (>= 0.3.6)
activemodel (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activerecord (7.2.0.alpha)
activemodel (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activemodel (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activerecord (7.2.0.beta3)
activemodel (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
timeout (>= 0.4.0)
activestorage (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activestorage (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
marcel (~> 1.0)
activesupport (7.2.0.alpha)
activesupport (7.2.0.beta3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.alpha)
actioncable (= 7.2.0.alpha)
actionmailbox (= 7.2.0.alpha)
actionmailer (= 7.2.0.alpha)
actionpack (= 7.2.0.alpha)
actiontext (= 7.2.0.alpha)
actionview (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activemodel (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activestorage (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
rails (7.2.0.beta3)
actioncable (= 7.2.0.beta3)
actionmailbox (= 7.2.0.beta3)
actionmailer (= 7.2.0.beta3)
actionpack (= 7.2.0.beta3)
actiontext (= 7.2.0.beta3)
actionview (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activemodel (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
bundler (>= 1.15.0)
railties (= 7.2.0.alpha)
railties (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
railties (= 7.2.0.beta3)
railties (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -118,19 +109,19 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.930.0)
aws-sdk-core (3.196.1)
aws-partitions (1.952.0)
aws-sdk-core (3.201.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.81.0)
aws-sdk-core (~> 3, >= 3.193.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.151.0)
aws-sdk-core (~> 3, >= 3.194.0)
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-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
@@ -148,7 +139,7 @@ GEM
msgpack (~> 1.2)
brakeman (6.1.2)
racc
builder (3.2.4)
builder (3.3.0)
capybara (3.40.0)
addressable
matrix
@@ -160,7 +151,7 @@ GEM
xpath (~> 3.2)
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.2.3)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -184,28 +175,36 @@ GEM
rainbow
rubocop
smart_properties
erubi (1.12.0)
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faraday (2.9.0)
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.10.0)
faraday-net_http (>= 2.0, < 3.2)
logger
faraday-net_http (3.1.0)
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 (3.28.3)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
fugit (>= 1.1)
railties (>= 6.0.0)
thor (>= 0.14.1)
good_job (4.1.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.1.0)
highline (3.0.1)
hotwire-livereload (1.4.0)
@@ -224,7 +223,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)
@@ -235,7 +234,7 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2)
irb (1.13.1)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
@@ -249,6 +248,7 @@ GEM
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -259,15 +259,15 @@ 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.23.0)
mocha (2.3.0)
minitest (5.24.1)
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
net-http (0.4.1)
uri
net-imap (0.4.11)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@@ -277,42 +277,41 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.5-aarch64-linux)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.5-arm-linux)
nokogiri (1.16.7-arm-linux)
racc (~> 1.4)
nokogiri (1.16.5-arm64-darwin)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86-linux)
nokogiri (1.16.7-x86-linux)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-darwin)
nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
octokit (8.1.0)
base64
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.4.0)
pagy (9.0.3)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
prism (0.27.0)
propshaft (0.8.0)
pg (1.5.7)
prism (0.30.0)
propshaft (0.9.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.1.2)
stringio
public_suffix (5.0.5)
public_suffix (5.1.0)
puma (6.4.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.0)
rack (3.0.11)
racc (1.8.1)
rack (3.1.7)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -338,13 +337,16 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rdoc (6.6.3.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.7)
reline (0.5.9)
io-console (~> 0.5)
rexml (3.2.8)
strscan (>= 3.0.9)
rexml (3.3.2)
strscan
rubocop (1.63.5)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
@@ -374,30 +376,32 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.16.6)
ruby-lsp (0.17.8)
language_server-protocol (~> 3.17.0)
prism (>= 0.23.0, < 0.28)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.6)
ruby-lsp (>= 0.16.5, < 0.17.0)
sorbet-runtime (>= 0.5.9897)
ruby-lsp-rails (0.3.11)
ruby-lsp (>= 0.17.2, < 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.21.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.17.3)
sentry-rails (5.18.2)
railties (>= 5.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -407,29 +411,29 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11383)
sorbet-runtime (0.5.11491)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.0)
tailwindcss-rails (2.6.4)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-aarch64-linux)
tailwindcss-rails (2.6.4-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-arm-linux)
tailwindcss-rails (2.6.4-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-arm64-darwin)
tailwindcss-rails (2.6.4-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-x86_64-darwin)
tailwindcss-rails (2.6.4-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-x86_64-linux)
tailwindcss-rails (2.6.4-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)
@@ -444,18 +448,18 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.23.0)
webmock (3.23.1)
addressable (>= 2.8.0)
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.14)
zeitwerk (2.6.17)
PLATFORMS
aarch64-linux
@@ -476,6 +480,7 @@ DEPENDENCIES
debug
dotenv-rails
erb_lint
faker
faraday
faraday-retry
good_job
@@ -494,7 +499,7 @@ DEPENDENCIES
puma (>= 5.0)
rails!
rails-settings-cached
ransack!
redcarpet
rubocop-rails-omakase
ruby-lsp-rails
selenium-webdriver

View File

@@ -35,6 +35,11 @@ There are 3 primary ways to use the Maybe app:
## Local Development Setup
**If you are trying to _self-host_ the Maybe app, stop here. You
should [read this guide to get started](docs/hosting/docker.md).**
The instructions below are for developers to get started with contributing to the app.
### Requirements
- Ruby 3.3.1

View File

@@ -7,43 +7,25 @@
details > summary::-webkit-details-marker {
@apply hidden;
}
details > summary {
@apply list-none;
}
}
@layer components {
.prose {
table {
@apply divide-y divide-gray-300;
}
tr {
@apply divide-x divide-gray-100;
}
th {
@apply whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900;
}
tbody {
@apply divide-y divide-gray-200;
}
td {
@apply px-2 py-2 text-sm text-gray-500 whitespace-nowrap;
}
}
.form-field {
@apply relative border border-alpha-black-100 bg-white rounded-md shadow-xs;
@apply focus-within:shadow-none focus-within:border-gray-900 focus-within:ring-4 focus-within:ring-gray-100;
@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 px-3 pt-2 pb-0 block text-xs text-gray-500;
@apply block text-xs text-gray-500;
}
.form-field__input {
@apply px-3 pb-2 pt-1 text-sm w-full bg-transparent border-none opacity-100;
@apply focus:outline-none focus:ring-0 focus: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;
}
@@ -53,12 +35,61 @@
}
.form-field__submit {
@apply w-full p-3 text-center text-white bg-black rounded-lg cursor-pointer hover:bg-gray-700;
@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 {
transform: translateX(100%);
}
[type='checkbox'].maybe-checkbox {
@apply rounded-sm;
}
[type='checkbox'].maybe-checkbox--light {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
}
[type='checkbox'].maybe-checkbox--dark {
@apply ring-gray-900 checked:text-white;
}
[type='checkbox'].maybe-checkbox--dark:checked {
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;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
}
.prose--github-release-notes {
.octicon {
@apply inline-block overflow-visible align-text-bottom fill-current;
}
.dropdown-caret {
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
}
.user-mention {
@apply font-bold;
}
}
}
/* 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

@@ -0,0 +1,109 @@
class Account::EntriesController < ApplicationController
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 trades
@trades = @account.entries.where(entryable_type: [ "Account::Transaction", "Account::Trade" ]).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[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
end
def update
@entry.assign_attributes entry_params
@entry.amount = amount if nature.present?
@entry.save!
@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
def show
end
def destroy
@entry.destroy!
@entry.sync_account_later
redirect_back_or_to account_url(@entry.account), notice: t(".success")
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 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: {})
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

@@ -1,4 +1,4 @@
class Accounts::LogosController < ApplicationController
class Account::LogosController < ApplicationController
def show
@account = Current.family.accounts.find(params[:account_id])
render_placeholder

View File

@@ -0,0 +1,45 @@
class Account::TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: :destroy
def new
@transfer = Account::Transfer.new
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
date: transfer_params[:date],
amount: transfer_params[:amount].to_d,
currency: transfer_params[:currency],
name: transfer_params[:name]
if @transfer.save
@transfer.entries.each(&:sync_account_later)
redirect_to transactions_path, notice: t(".success")
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[:alert] = @transfer.errors.full_messages.to_sentence
redirect_to transactions_path
end
end
def destroy
@transfer.destroy_and_remove_marks!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
@transfer = Account::Transfer.find(params[:id])
end
def transfer_params
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
end
end

View File

@@ -1,11 +1,12 @@
class AccountsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
include Filterable
before_action :set_account, only: %i[ show update destroy sync ]
before_action :set_account, only: %i[ edit show destroy sync update ]
def index
@accounts = Current.family.accounts
@institutions = Current.family.institutions
@accounts = Current.family.accounts.ungrouped.alphabetically
end
def summary
@@ -18,54 +19,45 @@ 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])
end
end
def show
@balance_series = @account.series(period: @period)
@valuation_series = @account.valuations.to_series
@series = @account.series(period: @period)
@trend = @series.trend
end
def edit
end
def update
if @account.update(account_params.except(:accountable_type))
@account.sync_later if account_params[:is_active] == "1" && @account.can_sync?
respond_to do |format|
format.html { redirect_to accounts_path, notice: t(".success") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
turbo_stream.replace("account_#{@account.id}", partial: "accounts/account", locals: { account: @account })
]
end
end
else
render "show", status: :unprocessable_entity
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
def create
@account = Current.family.accounts.build(account_params.except(:accountable_type, :start_date))
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
if @account.save
@valuation = @account.valuations.new(date: account_params[:start_date] || Date.today, value: @account.balance, currency: @account.currency)
@valuation.save!
redirect_to accounts_path, notice: t(".success")
else
render "new", status: :unprocessable_entity
end
@account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
rescue ActiveRecord::RecordInvalid => e
redirect_back_or_to accounts_path, alert: e.record.errors.full_messages.to_sentence
end
def destroy
@@ -74,24 +66,16 @@ class AccountsController < ApplicationController
end
def sync
if @account.can_sync?
unless @account.syncing?
@account.sync_later
respond_to do |format|
format.html { redirect_to account_path(@account), notice: t(".success") }
format.turbo_stream do
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
end
end
else
respond_to do |format|
format.html { redirect_to account_path(@account), notice: t(".cannot_sync") }
format.turbo_stream do
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "error", content: { body: t(".cannot_sync") } })
end
end
end
end
def sync_all
Current.family.accounts.active.sync
redirect_back_or_to accounts_path, notice: t(".success")
end
private
def set_account
@@ -99,6 +83,6 @@ class AccountsController < ApplicationController
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
end

View File

@@ -1,21 +1,15 @@
class ApplicationController < ActionController::Base
include Authentication, Invitable, SelfHostable
include AutoSync, Authentication, Invitable, SelfHostable
include Pagy::Backend
before_action :sync_accounts
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 sync_accounts
return if Current.user.blank?
def with_sidebar
return "turbo_rails/frame" if turbo_frame_request?
if Current.user.last_login_at.nil? || Current.user.last_login_at.before?(Date.current.beginning_of_day)
Current.family.sync_accounts
"with_sidebar"
end
end
end

View File

@@ -1,20 +1,20 @@
class Transactions::CategoriesController < ApplicationController
layout "with_sidebar"
class CategoriesController < ApplicationController
layout :with_sidebar
before_action :set_category, only: %i[ edit update ]
before_action :set_transaction, only: :create
def index
@categories = Current.family.transaction_categories.alphabetically
@categories = Current.family.categories.alphabetically
end
def new
@category = Current.family.transaction_categories.new color: Transaction::Category::COLORS.sample
@category = Current.family.categories.new color: Category::COLORS.sample
end
def create
Transaction::Category.transaction do
category = Current.family.transaction_categories.create!(category_params)
Category.transaction do
category = Current.family.categories.create!(category_params)
@transaction.update!(category_id: category.id) if @transaction
end
@@ -32,7 +32,7 @@ class Transactions::CategoriesController < ApplicationController
private
def set_category
@category = Current.family.transaction_categories.find(params[:id])
@category = Current.family.categories.find(params[:id])
end
def set_transaction
@@ -42,6 +42,6 @@ class Transactions::CategoriesController < ApplicationController
end
def category_params
params.require(:transaction_category).permit(:name, :color)
params.require(:category).permit(:name, :color)
end
end

View File

@@ -1,5 +1,5 @@
class Transactions::Categories::DeletionsController < ApplicationController
layout "with_sidebar"
class Category::DeletionsController < ApplicationController
layout :with_sidebar
before_action :set_category
before_action :set_replacement_category, only: :create
@@ -15,12 +15,12 @@ class Transactions::Categories::DeletionsController < ApplicationController
private
def set_category
@category = Current.family.transaction_categories.find(params[:category_id])
@category = Current.family.categories.find(params[:category_id])
end
def set_replacement_category
if params[:replacement_category_id].present?
@replacement_category = Current.family.transaction_categories.find(params[:replacement_category_id])
@replacement_category = Current.family.categories.find(params[:replacement_category_id])
end
end
end

View File

@@ -1,4 +1,4 @@
class Transactions::Categories::DropdownsController < ApplicationController
class Category::DropdownsController < ApplicationController
before_action :set_from_params
def show
@@ -17,6 +17,6 @@ class Transactions::Categories::DropdownsController < ApplicationController
end
def categories_scope
Current.family.transaction_categories.alphabetically
Current.family.categories.alphabetically
end
end

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

@@ -9,7 +9,8 @@ class ImportsController < ApplicationController
end
def new
@import = Import.new
account = Current.family.accounts.find_by(id: params[:account_id])
@import = Import.new account: account
end
def edit
@@ -37,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

@@ -0,0 +1,35 @@
class InstitutionsController < ApplicationController
before_action :set_institution, except: %i[ new create ]
def new
@institution = Institution.new
end
def create
Current.family.institutions.create!(institution_params)
redirect_to accounts_path, notice: t(".success")
end
def edit
end
def update
@institution.update!(institution_params)
redirect_to accounts_path, notice: t(".success")
end
def destroy
@institution.destroy!
redirect_to accounts_path, notice: t(".success")
end
private
def institution_params
params.require(:institution).permit(:name, :logo)
end
def set_institution
@institution = Current.family.institutions.find(params[:id])
end
end

View File

@@ -0,0 +1,41 @@
class MerchantsController < ApplicationController
layout :with_sidebar
before_action :set_merchant, only: %i[ edit update destroy ]
def index
@merchants = Current.family.merchants.alphabetically
end
def new
@merchant = Merchant.new
end
def create
Current.family.merchants.create!(merchant_params)
redirect_to merchants_path, notice: t(".success")
end
def edit
end
def update
@merchant.update!(merchant_params)
redirect_to merchants_path, notice: t(".success")
end
def destroy
@merchant.destroy!
redirect_to merchants_path, notice: t(".success")
end
private
def set_merchant
@merchant = Current.family.merchants.find(params[:id])
end
def merchant_params
params.require(:merchant).permit(:name, :color)
end
end

View File

@@ -1,5 +1,5 @@
class PagesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
include Filterable
@@ -21,7 +21,7 @@ class PagesController < ApplicationController
@accounts = Current.family.accounts
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transactions = Current.family.transactions.limit(5).order(date: :desc)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|
@@ -31,6 +31,7 @@ class PagesController < ApplicationController
end
def changelog
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
end
def feedback

View File

@@ -16,7 +16,7 @@ class RegistrationsController < ApplicationController
@user.role = :admin
if @user.save
Transaction::Category.create_default_categories(@user.family)
Category.create_default_categories(@user.family)
login @user
flash[:notice] = t(".success")
redirect_to root_path

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
@@ -47,7 +47,7 @@ class Settings::HostingsController < SettingsController
end
end
if hosting_params[:upgrades_mode] != "manual" && hosting_params[:render_deploy_hook].blank?
if hosting_params[:upgrades_mode] == "auto" && hosting_params[:render_deploy_hook].blank?
@errors.add(:render_deploy_hook, t("settings.hostings.update.render_deploy_hook_error"))
end

View File

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

View File

@@ -1,5 +1,5 @@
class Tags::DeletionsController < ApplicationController
layout "with_sidebar"
class Tag::DeletionsController < ApplicationController
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,41 +0,0 @@
class Transactions::MerchantsController < ApplicationController
layout "with_sidebar"
before_action :set_merchant, only: %i[ edit update destroy ]
def index
@merchants = Current.family.transaction_merchants
end
def new
@merchant = Transaction::Merchant.new
end
def create
Current.family.transaction_merchants.create!(merchant_params)
redirect_to transaction_merchants_path, notice: t(".success")
end
def edit
end
def update
@merchant.update!(merchant_params)
redirect_to transaction_merchants_path, notice: t(".success")
end
def destroy
@merchant.destroy!
redirect_to transaction_merchants_path, notice: t(".success")
end
private
def set_merchant
@merchant = Current.family.transaction_merchants.find(params[:id])
end
def merchant_params
params.require(:transaction_merchant).permit(:name, :color)
end
end

View File

@@ -1,6 +0,0 @@
class Transactions::RulesController < ApplicationController
layout "with_sidebar"
def index
end
end

View File

@@ -1,169 +1,104 @@
class TransactionsController < ApplicationController
layout "with_sidebar"
before_action :set_transaction, only: %i[ show edit update destroy ]
layout :with_sidebar
def index
search_params = session[ransack_session_key] || params[:q]
@q = Current.family.transactions.ransack(search_params)
result = @q.result.order(date: :desc)
@pagy, @transactions = pagy(result, items: 10)
@q = search_params
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
@totals = {
count: result.count,
income: result.inflows.sum(&:amount_money).abs,
expense: result.outflows.sum(&:amount_money).abs
count: result.select { |t| t.currency == Current.family.currency }.count,
income: result.income_total(Current.family.currency).abs,
expense: result.expense_total(Current.family.currency)
}
@filter_list = Transaction.build_filter_list(search_params, Current.family)
respond_to do |format|
format.html
format.turbo_stream
end
end
def search
if params[:clear]
session.delete(ransack_session_key)
elsif params[:remove_param]
current_params = session[ransack_session_key] || {}
if params[:remove_param] == "date_range"
updated_params = current_params.except("date_gteq", "date_lteq")
elsif params[:remove_param_value]
key_to_remove = params[:remove_param]
value_to_remove = params[:remove_param_value]
updated_params = current_params.deep_dup
updated_params[key_to_remove] = updated_params[key_to_remove] - [ value_to_remove ]
else
updated_params = current_params.except(params[:remove_param])
end
session[ransack_session_key] = updated_params
elsif params[:q]
session[ransack_session_key] = params[:q]
end
index
respond_to do |format|
format.html { render :index }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("transactions_summary", partial: "transactions/summary", locals: { totals: @totals }),
turbo_stream.replace("transactions_search_form", partial: "transactions/search_form", locals: { q: @q }),
turbo_stream.replace("transactions_filters", partial: "transactions/filters", locals: { filters: @filter_list }),
turbo_stream.replace("transactions_list", partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy })
]
end
end
end
def show
end
def new
@transaction = Transaction.new.tap do |txn|
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
if params[:account_id]
txn.account = Current.family.accounts.find(params[:account_id])
e.account = Current.family.accounts.find(params[:account_id])
end
end
end
def edit
end
def create
@transaction = Current.family.accounts
.find(params[:transaction][:account_id])
.transactions.build(transaction_params.merge(amount: amount))
@entry = Current.family
.accounts
.find(params[:account_entry][:account_id])
.entries
.create!(transaction_entry_params.merge(amount: amount))
respond_to do |format|
if @transaction.save
@transaction.account.sync_later(@transaction.date)
format.html { redirect_to transactions_url, notice: t(".success") }
else
format.html { render :new, status: :unprocessable_entity }
end
end
@entry.sync_account_later
redirect_back_or_to account_path(@entry.account), notice: t(".success")
end
def update
respond_to do |format|
sync_start_date = if transaction_params[:date]
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
else
@transaction.date
end
if params[:transaction][:tag_id].present?
tag = Current.family.tags.find(params[:transaction][:tag_id])
@transaction.tags << tag unless @transaction.tags.include?(tag)
end
if params[:transaction][:remove_tag_id].present?
@transaction.tags.delete(params[:transaction][:remove_tag_id])
end
if @transaction.update(transaction_params)
@transaction.account.sync_later(sync_start_date)
format.html { redirect_to transaction_url(@transaction), notice: t(".success") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
turbo_stream.replace("transaction_#{@transaction.id}", partial: "transactions/transaction", locals: { transaction: @transaction })
]
end
else
format.html { render :edit, status: :unprocessable_entity }
end
end
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end
def destroy
@account = @transaction.account
sync_start_date = @account.transactions.where("date < ?", @transaction.date).order(date: :desc).first&.date
@transaction.destroy!
@account.sync_later(sync_start_date)
def bulk_edit
end
respond_to do |format|
format.html { redirect_to transactions_url, notice: t(".success") }
end
def bulk_update
updated = Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params)
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
def rules
end
private
def delete_search_param(params, key, value: nil)
if value
params[key]&.delete(value)
params.delete(key) if params[key].empty? # Remove key if it's empty after deleting value
else
params.delete(key)
end
params
end
def ransack_session_key
:ransack_transactions_q
end
# Use callbacks to share common setup or constraints between actions.
def set_transaction
@transaction = Transaction.find(params[:id])
end
def amount
if nature.income?
transaction_params[:amount].to_d * -1
transaction_entry_params[:amount].to_d * -1
else
transaction_params[:amount].to_d
transaction_entry_params[:amount].to_d
end
end
def nature
params[:transaction][:nature].to_s.inquiry
params[:account_entry][:nature].to_s.inquiry
end
def transaction_params
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, :tag_id, :remove_tag_id).except(:tag_id, :remove_tag_id)
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])
end
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
end
def search_params
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
end
def transaction_entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
end
end

View File

@@ -1,70 +0,0 @@
class ValuationsController < ApplicationController
before_action :set_valuation, only: %i[ edit update destroy ]
def create
@account = Current.family.accounts.find(params[:account_id])
# TODO: placeholder logic until we have a better abstraction for trends
@valuation = @account.valuations.new(valuation_params.merge(currency: @account.currency))
if @valuation.save
@valuation.account.sync_later(@valuation.date)
respond_to do |format|
format.html { redirect_to account_path(@account), notice: "Valuation created" }
format.turbo_stream
end
else
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
flash.now[:error] = "Valuation already exists for this date"
render :new, status: :unprocessable_entity
end
def show
@valuation = Current.family.accounts.find(params[:account_id]).valuations.find(params[:id])
end
def edit
end
def update
sync_start_date = [ @valuation.date, Date.parse(valuation_params[:date]) ].compact.min
if @valuation.update(valuation_params)
@valuation.account.sync_later(sync_start_date)
redirect_to account_path(@valuation.account), notice: "Valuation updated"
else
render :edit, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
flash.now[:error] = "Valuation already exists for this date"
render :edit, status: :unprocessable_entity
end
def destroy
@account = @valuation.account
sync_start_date = @account.valuations.where("date < ?", @valuation.date).order(date: :desc).first&.date
@valuation.destroy!
@account.sync_later(sync_start_date)
respond_to do |format|
format.html { redirect_to account_path(@account), notice: "Valuation deleted" }
format.turbo_stream
end
end
def new
@account = Current.family.accounts.find(params[:account_id])
@valuation = @account.valuations.new
end
private
# Use callbacks to share common setup or constraints between actions.
def set_valuation
@valuation = Valuation.find(params[:id])
end
def valuation_params
params.require(:valuation).permit(:date, :value)
end
end

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

@@ -0,0 +1,39 @@
module Account::EntriesHelper
def permitted_entryable_partial_path(entry, relative_partial_path)
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
end
def unconfirmed_transfer?(entry)
entry.marked_as_transfer? && entry.transfer.nil?
end
def transfer_entries(entries)
transfers = entries.select { |e| e.transfer_id.present? }
transfers.map(&:transfer).uniq
end
def entry_icon(entry, is_oldest: false)
if is_oldest
"keyboard"
elsif entry.trend.direction.up?
"arrow-up"
elsif entry.trend.direction.down?
"arrow-down"
else
"minus"
end
end
def entry_style(entry, is_oldest: false)
color = is_oldest ? "#D444F1" : entry.trend.color
mixed_hex_styles(color)
end
private
def permitted_entryable_key(entry)
permitted_entryable_paths = %w[transaction valuation trade]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View File

@@ -0,0 +1,2 @@
module Account::TransfersHelper
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: valuation_account_entries_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_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)
{
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Account::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,147 +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.send(method)
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,
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,30 +13,55 @@ module ApplicationHelper
name.underscore
end
def notification(text, **options, &block)
content = tag.p(text)
content = capture &block if block_given?
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
def family_notifications_stream
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
# Wrap view with <%= modal do %> ... <% end %> to have it open in a modal
# Make sure to add data-turbo-frame="modal" to the link/button that opens the modal
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
##
# Helper to open a centered and overlayed modal with custom contents
#
# @example Basic usage
# <%= modal classes: "custom-class" do %>
# <div>Content here</div>
# <% end %>
#
def modal(options = {}, &block)
content = capture &block
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
end
##
# Helper to open a drawer on the right side of the screen with custom contents
#
# @example Basic usage
# <%= drawer do %>
# <div>Content here</div>
# <% end %>
#
def drawer(&block)
content = capture &block
render partial: "shared/drawer", locals: { content: content }
end
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children, liabilities.children ].flatten
end
def sidebar_modal(&block)
content = capture &block
render partial: "shared/sidebar_modal", locals: { content: content }
end
def sidebar_link_to(name, path, options = {})
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
@@ -51,6 +76,20 @@ module ApplicationHelper
end
end
def mixed_hex_styles(hex)
color = hex || "#1570EF" # blue-600
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
color: #{color};
STYLE
end
def circle_logo(name, hex: nil, size: "md")
render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
end
def return_to_path(params, fallback = root_path)
uri = URI.parse(params[:return_to] || fallback)
uri.relative? ? uri.path : root_path
@@ -108,4 +147,11 @@ module ApplicationHelper
options.reverse_merge!(money.default_format_options)
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
end
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
collection.group_by(&:currency)
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
.map { |_currency, money| format_money(money) }
.join(separator)
end
end

View File

@@ -0,0 +1,7 @@
module CategoriesHelper
def null_category
Category.new \
name: "Uncategorized",
color: Category::UNCATEGORIZED_COLOR
end
end

View File

@@ -1,7 +1,12 @@
module FormsHelper
def styled_form_with(**options, &block)
options[:builder] = StyledFormBuilder
form_with(**options, &block)
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,7 +16,60 @@ module FormsHelper
end
end
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 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 money_field(form, method, options = {})
value = form.object.send(method)
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 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,5 @@
module InstitutionsHelper
def institution_logo(institution)
institution.logo.attached? ? institution.logo : institution.logo_url
end
end

View File

@@ -6,6 +6,23 @@ module MenusHelper
end
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: nil)
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
concat(tag.span(label, class: "text-sm"))
end
end
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
button_to url,
method: :delete,
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
concat(tag.span(label, class: "text-sm"))
end
end
private
def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do

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,7 +0,0 @@
module Transactions::CategoriesHelper
def null_category
Transaction::Category.new \
name: "Uncategorized",
color: Transaction::Category::UNCATEGORIZED_COLOR
end
end

View File

@@ -1,24 +1,37 @@
module TransactionsHelper
def transaction_filters
def transaction_search_filters
[
{ name: "Account", partial: "account_filter", icon: "layers" },
{ name: "Date", partial: "date_filter", icon: "calendar" },
{ name: "Type", partial: "type_filter", icon: "shapes" },
{ name: "Amount", partial: "amount_filter", icon: "hash" },
{ name: "Category", partial: "category_filter", icon: "tag" },
{ name: "Merchant", partial: "merchant_filter", icon: "store" }
{ key: "account_filter", name: "Account", icon: "layers" },
{ key: "date_filter", name: "Date", icon: "calendar" },
{ key: "type_filter", name: "Type", icon: "shapes" },
{ key: "amount_filter", name: "Amount", icon: "hash" },
{ key: "category_filter", name: "Category", icon: "tag" },
{ key: "merchant_filter", name: "Merchant", icon: "store" }
]
end
def transaction_filter_id(filter)
"txn-#{filter[:name].downcase}-filter"
def get_transaction_search_filter_partial_path(filter)
"transactions/searches/filters/#{filter[:key]}"
end
def transaction_filter_by_name(name)
transaction_filters.find { |filter| filter[:name] == name }
def get_default_transaction_search_filter
transaction_search_filters[0]
end
def full_width_transaction_row?(route)
route != "/"
def transactions_path_without_param(param_key, param_value)
updated_params = request.query_parameters.deep_dup
q_params = updated_params[:q] || {}
current_value = q_params[param_key]
if current_value.is_a?(Array)
q_params[param_key] = current_value - [ param_value ]
else
q_params.delete(param_key)
end
updated_params[:q] = q_params
transactions_path(updated_params)
end
end

View File

@@ -1,2 +0,0 @@
module ValuationsHelper
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

@@ -0,0 +1,134 @@
import {Controller} from "@hotwired/stimulus"
// Connects to data-controller="bulk-select"
export default class extends Controller {
static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"]
static values = {
resource: String,
selectedIds: {type: Array, default: []}
}
connect() {
document.addEventListener("turbo:load", this.#updateView)
this.#updateView()
}
disconnect() {
document.removeEventListener("turbo:load", this.#updateView)
}
bulkEditDrawerTitleTargetConnected(element) {
element.innerText = `Edit ${this.selectedIdsValue.length} ${this.#pluralizedResourceName()}`
}
submitBulkRequest(e) {
const form = e.target.closest("form");
const scope = e.params.scope
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue)
form.requestSubmit()
}
togglePageSelection(e) {
if (e.target.checked) {
this.#selectAll()
} else {
this.deselectAll()
}
}
toggleGroupSelection(e) {
const group = this.groupTargets.find(group => group.contains(e.target))
this.#rowsForGroup(group).forEach(row => {
if (e.target.checked) {
this.#addToSelection(row.dataset.id)
} else {
this.#removeFromSelection(row.dataset.id)
}
})
}
toggleRowSelection(e) {
if (e.target.checked) {
this.#addToSelection(e.target.dataset.id)
} else {
this.#removeFromSelection(e.target.dataset.id)
}
}
deselectAll() {
this.selectedIdsValue = []
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
}
selectedIdsValueChanged() {
this.#updateView()
}
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
this.#resetFormInputs(form, paramName);
transactionIds.forEach(id => {
const input = document.createElement("input");
input.type = 'hidden'
input.name = paramName
input.value = id
form.appendChild(input)
})
}
#resetFormInputs(form, paramName) {
const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);
existingInputs.forEach((input) => input.remove());
}
#rowsForGroup(group) {
return this.rowTargets.filter(row => group.contains(row))
}
#addToSelection(idToAdd) {
this.selectedIdsValue = Array.from(
new Set([...this.selectedIdsValue, idToAdd])
)
}
#removeFromSelection(idToRemove) {
this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove)
}
#selectAll() {
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
}
#updateView = () => {
this.#updateSelectionBar()
this.#updateGroups()
this.#updateRows()
}
#updateSelectionBar() {
const count = this.selectedIdsValue.length
this.selectionBarTextTarget.innerText = `${count} ${this.#pluralizedResourceName()} selected`
this.selectionBarTarget.hidden = count === 0
this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0
}
#pluralizedResourceName() {
return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}`
}
#updateGroups() {
this.groupTargets.forEach(group => {
const rows = this.rowTargets.filter(row => group.contains(row))
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
group.querySelector("input[type='checkbox']").checked = groupSelected
})
}
#updateRows() {
this.rowTargets.forEach(row => {
row.checked = this.selectedIdsValue.includes(row.dataset.id)
})
}
}

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

@@ -8,7 +8,7 @@ export default class extends Controller {
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
this.templateTarget.classList.add("hidden");
this.clearBtnTarget.classList.remove("hidden");
};

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

@@ -1,7 +1,7 @@
class AccountSyncJob < ApplicationJob
queue_as :default
def perform(account, start_date = nil)
account.sync(start_date)
def perform(account, start_date: nil)
account.sync(start_date: start_date)
end
end

View File

@@ -2,86 +2,107 @@ class Account < ApplicationRecord
include Syncable
include Monetizable
validates :family, presence: true
validates :name, :balance, :currency, presence: true
broadcasts_refreshes
belongs_to :family
belongs_to :institution, optional: true
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 :valuations, dependent: :destroy
has_many :transactions, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
monetize :balance
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true) }
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
scope :ungrouped, -> { where(institution_id: nil) }
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
def self.ransackable_attributes(auth_object = nil)
%w[name id]
end
delegate :value, :series, to: :accountable
def balance_on(date)
balances.where("date <= ?", date).order(date: :desc).first&.balance
end
class << self
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
def multi_currency?
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
currencies.count > 1
end
# e.g. Accounts denominated in currency other than family currency
def foreign_currency?
currency != family.currency
end
def self.by_provider
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
end
def self.some_syncing?
exists?(status: "syncing")
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
converted_balance = balance_money.exchange_to(currency)
if converted_balance
TimeSeries.new([ { date: Date.current, value: converted_balance } ])
else
TimeSeries.new([])
end
else
TimeSeries.from_collection(balance_series, :balance_money)
end
end
def self.by_group(period: Period.all, currency: Money.default_currency)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types|
types.each do |type|
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
self.where(accountable_type: type).each do |account|
value_node = group.add_value_node(
account,
account.balance_money.exchange_to(currency) || Money.new(0, currency),
account.series(period: period, currency: currency)
)
Accountable.by_classification.each do |classification, types|
types.each do |type|
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
self.where(accountable_type: type).each do |account|
group.add_value_node(
account,
account.balance_money.exchange_to(currency, fallback_rate: 0),
account.series(period: period, currency: currency)
)
end
end
end
grouped_accounts
end
grouped_accounts
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
account = self.new(attributes.except(:accountable_type))
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
# Always build the initial valuation
account.entries.build \
date: Date.current,
amount: attributes[:balance],
currency: account.currency,
entryable: Account::Valuation.new
# Conditionally build the optional start valuation
if start_date.present? && start_balance.present?
account.entries.build \
date: start_date,
amount: start_balance,
currency: account.currency,
entryable: Account::Valuation.new
end
account.save!
account
end
end
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current)
if valuation
valuation.update! amount: balance
else
entries.create! \
date: Date.current,
amount: balance,
currency: currency,
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

@@ -5,4 +5,5 @@ class Account::Balance < ApplicationRecord
validates :account, :date, :balance, presence: true
monetize :balance
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
end

View File

@@ -1,108 +0,0 @@
class Account::Balance::Calculator
attr_reader :daily_balances, :errors, :warnings
@daily_balances = []
@errors = []
@warnings = []
def initialize(account, options = {})
@account = account
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
end
def calculate
prior_balance = implied_start_balance
calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date|
valuation = normalized_valuations.find { |v| v["date"] == date }
if valuation
current_balance = valuation["value"]
else
txn_flows = transaction_flows(date)
current_balance = prior_balance - txn_flows
end
prior_balance = current_balance
{ date:, balance: current_balance, currency: @account.currency, updated_at: Time.current }
end
@daily_balances = [
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
*calculated_balances
]
if @account.foreign_currency?
converted_balances = convert_balances_to_family_currency
@daily_balances.concat(converted_balances)
end
self
end
private
def convert_balances_to_family_currency
rates = ExchangeRate.get_rate_series(
@account.currency,
@account.family.currency,
@calc_start_date..Date.current
).to_a
@daily_balances.map do |balance|
rate = rates.find { |rate| rate.date == balance[:date] }
raise "Rate for #{@account.currency} to #{@account.family.currency} on #{balance[:date]} not found" if rate.nil?
converted_balance = balance[:balance] * rate.rate
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
end
end
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
def normalize_entries_to_account_currency(entries, value_key)
entries.map do |entry|
currency = entry.currency
date = entry.date
value = entry.send(value_key)
if currency != @account.currency
value = ExchangeRate.convert(value:, from: currency, to: @account.currency, date:)
currency = @account.currency
end
entry.attributes.merge(value_key.to_s => value, "currency" => currency)
end
end
def normalized_valuations
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
end
def normalized_transactions
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
end
def transaction_flows(date)
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
flows *= -1 if @account.classification == "liability"
flows
end
def implied_start_balance
if @calc_start_date > @account.effective_start_date
return @account.balance_on(@calc_start_date)
end
oldest_valuation_date = normalized_valuations.first&.dig("date")
oldest_transaction_date = normalized_transactions.first&.dig("date")
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
oldest_valuation["value"].to_d
else
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
net_transaction_flows *= -1 if @account.classification == "liability"
@account.balance.to_d + net_transaction_flows
end
end
end

View File

@@ -0,0 +1,127 @@
class Account::Balance::Syncer
attr_reader :warnings
def initialize(account, start_date: nil)
@account = account
@warnings = []
@sync_start_date = calculate_sync_start_date(start_date)
end
def run
daily_balances = calculate_daily_balances
daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency
Account::Balance.transaction do
upsert_balances!(daily_balances)
purge_stale_balances!
if daily_balances.any?
account.reload
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
end
end
end
private
attr_reader :sync_start_date, :account
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
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])
end
def purge_stale_balances!
account.balances.delete_by("date < ?", account_start_date)
end
def calculate_balance_for_date(date, entries:, prior_balance:)
valuation = entries.find { |e| e.date == date && e.account_valuation? }
return valuation.amount if valuation
return derived_sync_start_balance(entries) unless prior_balance
entries = entries.select { |e| e.date == date }
prior_balance - net_entry_flows(entries)
end
def calculate_daily_balances
entries = account.entries.where("date >= ?", sync_start_date).to_a
prior_balance = find_prior_balance
(sync_start_date..Date.current).map do |date|
current_balance = calculate_balance_for_date(date, entries:, prior_balance:)
prior_balance = current_balance
build_balance(date, current_balance)
end
end
def calculate_converted_balances(balances)
from_currency = account.currency
to_currency = account.family.currency
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
rescue Money::ConversionError
@warnings << "missing exchange rates from #{from_currency} to #{to_currency}"
[]
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
def derived_sync_start_balance(entries)
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
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_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_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end
def account_start_date
@account_start_date ||= begin
oldest_entry_date = account.entries.chronological.first.try(:date)
return Date.current unless oldest_entry_date
oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists?
oldest_entry_date -= 1 unless oldest_entry_is_valuation
oldest_entry_date
end
end
def calculate_sync_start_date(provided_start_date)
[ provided_start_date, account_start_date ].compact.max
end
end

View File

@@ -1,3 +0,0 @@
class Account::Credit < ApplicationRecord
include Accountable
end

View File

@@ -1,3 +0,0 @@
class Account::Crypto < ApplicationRecord
include Accountable
end

View File

@@ -1,3 +0,0 @@
class Account::Depository < ApplicationRecord
include Accountable
end

211
app/models/account/entry.rb Normal file
View File

@@ -0,0 +1,211 @@
class Account::Entry < ApplicationRecord
include Monetizable
monetize :amount
belongs_to :account
belongs_to :transfer, optional: true
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
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) }
scope :without_transfers, -> { where(marked_as_transfer: false) }
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
select(
"account_entries.*",
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
}
def sync_account_later
if destroyed?
sync_start_date = previous_entry&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
end
account.sync_later(start_date: sync_start_date)
end
def inflow?
amount <= 0 && account_transaction?
end
def outflow?
amount > 0 && account_transaction?
end
def first_of_type?
first_entry = account
.entries
.where("entryable_type = ?", entryable_type)
.order(:date)
.first
first_entry&.id == id
end
def entryable_name_short
entryable_type.demodulize.underscore
end
def trend
@trend ||= create_trend
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(
"gs.date",
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
)
.from(entries.with_converted_amount(currency), :e)
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
.group("gs.date")
end
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
# Extend the period to include the rolling window
period_with_rolling = period.extend_backward(period.date_range.count.days)
# Aggregate the rolling sum of spending and income based on daily totals
rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling))
.select(
"*",
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
)
.order(:date)
# Trim the results to the original period
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
def mark_transfers!
update_all marked_as_transfer: true
# Attempt to "auto match" and save a transfer if 2 transactions selected
Account::Transfer.new(entries: all).save if all.count == 2
end
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
entryable_attributes: {
notes: bulk_update_params[:notes],
category_id: bulk_update_params[:category_id],
merchant_id: bulk_update_params[:merchant_id]
}.compact_blank
}.compact_blank
return 0 if bulk_attributes.blank?
transaction do
all.each do |entry|
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
entry.update! bulk_attributes
end
end
all.size
end
def income_total(currency = "USD")
without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
end
def expense_total(currency = "USD")
without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
end
def search(params)
query = all
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?
if params[:accounts].present? || params[:account_ids].present?
query = query.joins(:account)
end
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
# Search attributes on each entryable to further refine results
entryable_ids = entryable_search(params)
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
query
end
private
def entryable_search(params)
entryable_ids = []
entryable_search_performed = false
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
next unless entryable.requires_search?(params)
entryable_search_performed = true
entryable_ids += entryable.search(params).pluck(:id)
end
return nil unless entryable_search_performed
entryable_ids
end
end
private
def previous_entry
@previous_entry ||= account
.entries
.where("date < ?", date)
.where("entryable_type = ?", entryable_type)
.order(date: :desc)
.first
end
def create_trend
TimeSeries::Trend.new \
current: amount_money,
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
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.ticker} because you only own #{current_qty} shares")
end
end
end
end

View File

@@ -0,0 +1,13 @@
module Account::Entryable
extend ActiveSupport::Concern
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
def self.from_type(entryable_type)
entryable_type.presence_in(TYPES).constantize
end
included do
has_one :entry, as: :entryable, touch: true
end
end

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

@@ -1,16 +0,0 @@
class Account::Investment < ApplicationRecord
include Accountable
SUBTYPES = [
[ "Brokerage", "brokerage" ],
[ "Pension", "pension" ],
[ "Retirement", "retirement" ],
[ "401(k)", "401k" ],
[ "529 plan", "529_plan" ],
[ "Health Savings Account", "hsa" ],
[ "Mutual Fund", "mutual_fund" ],
[ "Roth IRA", "roth_ira" ],
[ "Roth 401k", "roth_401k" ],
[ "Angel", "angel" ]
].freeze
end

View File

@@ -1,3 +0,0 @@
class Account::Loan < ApplicationRecord
include Accountable
end

View File

@@ -1,3 +0,0 @@
class Account::OtherAsset < ApplicationRecord
include Accountable
end

View File

@@ -1,3 +0,0 @@
class Account::OtherLiability < ApplicationRecord
include Accountable
end

View File

@@ -1,3 +0,0 @@
class Account::Property < ApplicationRecord
include Accountable
end

View File

@@ -0,0 +1,83 @@
class Account::Sync < ApplicationRecord
belongs_to :account
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
class << self
def for(account, start_date: nil)
create! account: account, start_date: start_date
end
def latest
order(created_at: :desc).first
end
end
def run
start!
sync_balances
sync_holdings
complete!
rescue StandardError => error
fail! error
end
private
def sync_balances
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
syncer.run
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

@@ -1,86 +1,29 @@
module Account::Syncable
extend ActiveSupport::Concern
def sync_later(start_date = nil)
AccountSyncJob.perform_later(self, start_date)
end
def sync(start_date = nil)
update!(status: "syncing")
sync_exchange_rates
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
calculator.calculate
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
self.balances.where("date < ?", effective_start_date).delete_all
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
update!(status: "ok", last_sync_date: Date.today, balance: new_balance)
rescue => e
update!(status: "error")
Rails.logger.error("Failed to sync account #{id}: #{e.message}")
end
def can_sync?
# Skip account sync if account is not active or the sync process is already running
return false unless is_active
return false if syncing?
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
return true if last_sync_date.blank?
# If last_sync_date is not today, allow syncing
last_sync_date != Date.today
end
# The earliest date we can calculate a balance for
def effective_start_date
first_valuation_date = self.valuations.order(:date).pluck(:date).first
first_transaction_date = self.transactions.order(:date).pluck(:date).first
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
end
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
def sync_exchange_rates
rate_candidates = []
if multi_currency?
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
transactions_in_foreign_currency.each do |currency, date|
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
end
class_methods do
def sync(start_date: nil)
all.each { |a| a.sync_later(start_date: start_date) }
end
end
if foreign_currency?
(effective_start_date..Date.current).each do |date|
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
end
end
def syncing?
syncs.syncing.any?
end
existing_rates = ExchangeRate.where(
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
date: rate_candidates.map { |rc| rc[:date] }
).pluck(:base_currency, :converted_currency, :date)
def latest_sync_date
syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date
end
# Convert to a set for faster lookup
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
def needs_sync?
latest_sync_date.nil? || latest_sync_date < Date.current
end
rate_candidates.each do |rate_candidate|
rc_from = rate_candidate[:from_currency]
rc_to = rate_candidate[:to_currency]
rc_date = rate_candidate[:date]
def sync_later(start_date: nil)
AccountSyncJob.perform_later(self, start_date: start_date)
end
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
end
nil
def sync(start_date: nil)
Account::Sync.for(self, start_date: start_date).run
end
end

View File

@@ -0,0 +1,26 @@
class Account::Trade < ApplicationRecord
include Account::Entryable
belongs_to :security
validates :qty, presence: true, numericality: { other_than: 0 }
validates :price, 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,40 @@
class Account::Transaction < ApplicationRecord
include Account::Entryable
belongs_to :category, optional: true
belongs_to :merchant, optional: true
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
accepts_nested_attributes_for :taggings, allow_destroy: true
scope :active, -> { where(excluded: false) }
class << self
def search(params)
query = all
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
query
end
def requires_search?(params)
searchable_keys.any? { |key| params.key?(key) }
end
private
def searchable_keys
%i[ categories merchants ]
end
end
private
def previous_transaction_date
self.account
.transactions
.where("date < ?", date)
.order(date: :desc)
.first&.date
end
end

View File

@@ -0,0 +1,95 @@
class Account::Transfer < ApplicationRecord
has_many :entries, dependent: :nullify
validate :net_zero_flows, if: :single_currency_transfer?
validate :transaction_count, :from_different_accounts, :all_transactions_marked
def date
outflow_transaction&.date
end
def amount_money
entries.first&.amount_money&.abs || Money.new(0)
end
def from_name
outflow_transaction&.account&.name || I18n.t("account.transfer.from_fallback_name")
end
def to_name
inflow_transaction&.account&.name || I18n.t("account.transfer.to_fallback_name")
end
def name
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
end
def inflow_transaction
entries.find { |e| e.inflow? }
end
def outflow_transaction
entries.find { |e| e.outflow? }
end
def destroy_and_remove_marks!
transaction do
entries.each do |e|
e.update! marked_as_transfer: false
end
destroy!
end
end
class << self
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
outflow = from_account.entries.build \
amount: amount.abs,
currency: currency,
date: date,
name: name,
marked_as_transfer: true,
entryable: Account::Transaction.new
inflow = to_account.entries.build \
amount: amount.abs * -1,
currency: currency,
date: date,
name: name,
marked_as_transfer: true,
entryable: Account::Transaction.new
new entries: [ outflow, inflow ]
end
end
private
def single_currency_transfer?
entries.map { |e| e.currency }.uniq.size == 1
end
def transaction_count
unless entries.size == 2
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
end
def net_zero_flows
unless entries.sum(&:amount).zero?
errors.add :transactions, "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"
end
end
end

View File

@@ -0,0 +1,13 @@
class Account::Valuation < ApplicationRecord
include Account::Entryable
class << self
def search(_params)
all
end
def requires_search?(_params)
false
end
end
end

View File

@@ -1,3 +0,0 @@
class Account::Vehicle < ApplicationRecord
include Accountable
end

View File

@@ -1,5 +1,5 @@
class Transaction::Category < ApplicationRecord
has_many :transactions, dependent: :nullify
class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
belongs_to :family
validates :name, :color, :family, presence: true
@@ -23,16 +23,8 @@ class Transaction::Category < ApplicationRecord
{ internal_category: "home_improvement", color: COLORS[7] }
]
def self.ransackable_attributes(auth_object = nil)
%w[name id]
end
def self.ransackable_associations(auth_object = nil)
%w[]
end
def self.create_default_categories(family)
if family.transaction_categories.size > 0
if family.categories.size > 0
raise ArgumentError, "Family already has some categories"
end

View File

@@ -1,29 +1,36 @@
module Accountable
extend ActiveSupport::Concern
ASSET_TYPES = %w[ Account::Depository Account::Investment Account::Crypto Account::OtherAsset Account::Property Account::Vehicle ]
LIABILITY_TYPES = %w[ Account::Credit Account::Loan Account::OtherLiability ]
ASSET_TYPES = %w[ Depository Investment Crypto Property Vehicle OtherAsset ]
LIABILITY_TYPES = %w[ CreditCard Loan OtherLiability ]
TYPES = ASSET_TYPES + LIABILITY_TYPES
def self.from_type(type)
return nil unless types.include?(type) || TYPES.include?(type)
"Account::#{type.demodulize}".constantize
return nil unless TYPES.include?(type)
type.constantize
end
def self.by_classification
{ assets: ASSET_TYPES, liabilities: LIABILITY_TYPES }
end
def self.types(classification = nil)
types = classification ? (classification.to_sym == :asset ? ASSET_TYPES : LIABILITY_TYPES) : TYPES
types.map { |type| type.demodulize }
end
def self.classification(type)
ASSET_TYPES.include?(type) ? :asset : :liability
end
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,7 +6,7 @@ module Monetizable
fields.each do |field|
define_method("#{field}_money") do
value = self.send(field)
value.nil? ? nil : Money.new(value, currency)
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
end
end
end

View File

@@ -6,12 +6,25 @@ module Providable
extend ActiveSupport::Concern
class_methods do
def security_prices_provider
synth_provider
end
def exchange_rates_provider
Provider::Synth.new
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

@@ -0,0 +1,3 @@
class CreditCard < ApplicationRecord
include Accountable
end

3
app/models/crypto.rb Normal file
View File

@@ -0,0 +1,3 @@
class Crypto < ApplicationRecord
include Accountable
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

@@ -0,0 +1,351 @@
class Demo::Generator
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
def initialize
@family = reset_family!
end
def reset_and_clear_data!
clear_data!
create_user!
puts "user reset"
end
def reset_data!
Family.transaction do
clear_data!
create_user!
puts "user reset"
create_tags!
create_categories!
create_merchants!
puts "tags, categories, merchants created"
create_credit_card_account!
create_checking_account!
create_savings_account!
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
puts "accounts created"
puts "Demo data loaded successfully!"
end
end
private
attr_reader :family
def reset_family!
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
family = Family.find_by(id: family_id)
family.destroy! if family
Family.create!(id: family_id, name: "Demo Family").tap(&:reload)
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!
family.users.create! \
email: "user@maybe.local",
first_name: "Demo",
last_name: "User",
password: "password"
end
def create_tags!
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
family.tags.create!(name: tag)
end
end
def create_categories!
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
"Personal Care", "General Services", "Auto & Transport",
"Rent & Utilities", "Home Improvement", "Shopping" ]
categories.each do |category|
family.categories.create!(name: category, color: COLORS.sample)
end
end
def create_merchants!
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
merchants.each do |merchant|
family.merchants.create!(name: merchant, color: COLORS.sample)
end
end
def create_credit_card_account!
cc = family.accounts.create! \
accountable: CreditCard.new,
name: "Chase Credit Card",
balance: 2300,
currency: "USD",
institution: family.institutions.find_or_create_by(name: "Chase")
50.times do
merchant = random_family_record(Merchant)
create_transaction! \
account: cc,
name: merchant.name,
amount: Faker::Number.positive(to: 200),
tags: [ tag_for_merchant(merchant) ],
category: category_for_merchant(merchant),
merchant: merchant
end
5.times do
create_transaction! \
account: cc,
amount: Faker::Number.negative(from: -1000),
name: "CC Payment"
end
end
def create_checking_account!
checking = family.accounts.create! \
accountable: Depository.new,
name: "Chase Checking",
balance: 15000,
currency: "USD",
institution: family.institutions.find_or_create_by(name: "Chase")
10.times do
create_transaction! \
account: checking,
name: "Expense",
amount: Faker::Number.positive(from: 100, to: 1000)
end
10.times do
create_transaction! \
account: checking,
amount: Faker::Number.negative(from: -2000),
name: "Income",
category: income_category
end
end
def create_savings_account!
savings = family.accounts.create! \
accountable: Depository.new,
name: "Demo Savings",
balance: 40000,
currency: "USD",
subtype: "savings",
institution: family.institutions.find_or_create_by(name: "Chase")
income_category = categories.find { |c| c.name == "Income" }
income_tag = tags.find { |t| t.name == "Emergency Fund" }
20.times do
create_transaction! \
account: savings,
amount: Faker::Number.negative(from: -2000),
tags: [ income_tag ],
category: income_category,
name: "Income"
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")
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)
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, security: security)
end
end
def create_house_and_mortgage!
house = family.accounts.create! \
accountable: Property.new,
name: "123 Maybe Way",
balance: 560000,
currency: "USD"
create_valuation!(house, 3.years.ago.to_date, 520000)
create_valuation!(house, 2.years.ago.to_date, 540000)
create_valuation!(house, 1.years.ago.to_date, 550000)
family.accounts.create! \
accountable: Loan.new,
name: "Mortgage",
balance: 495000,
currency: "USD"
end
def create_car_and_loan!
family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
balance: 18000,
currency: "USD"
family.accounts.create! \
accountable: Loan.new,
name: "Car Loan",
balance: 8000,
currency: "USD"
end
def create_transaction!(attributes = {})
entry_attributes = attributes.except(:category, :tags, :merchant)
transaction_attributes = attributes.slice(:category, :tags, :merchant)
entry_defaults = {
date: Faker::Number.between(from: 0, to: 90).days.ago.to_date,
currency: "USD",
entryable: Account::Transaction.new(transaction_attributes)
}
Account::Entry.create! entry_defaults.merge(entry_attributes)
end
def create_valuation!(account, date, amount)
Account::Entry.create! \
account: account,
date: date,
amount: amount,
currency: "USD",
entryable: Account::Valuation.new
end
def random_family_record(model)
family_records = model.where(family_id: family.id)
model.offset(rand(family_records.count)).first
end
def category_for_merchant(merchant)
mapping = {
"Amazon" => "Shopping",
"Starbucks" => "Food & Drink",
"McDonald's" => "Food & Drink",
"Target" => "Shopping",
"Costco" => "Food & Drink",
"Home Depot" => "Home Improvement",
"Shell" => "Auto & Transport",
"Whole Foods" => "Food & Drink",
"Walgreens" => "Personal Care",
"Nike" => "Shopping",
"Uber" => "Auto & Transport",
"Netflix" => "Entertainment",
"Spotify" => "Entertainment",
"Delta Airlines" => "Travel",
"Airbnb" => "Travel",
"Sephora" => "Personal Care"
}
categories.find { |c| c.name == mapping[merchant.name] }
end
def tag_for_merchant(merchant)
mapping = {
"Delta Airlines" => "Trips",
"Airbnb" => "Trips"
}
tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] }
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
def categories
@categories ||= family.categories
end
def tags
@tags ||= family.tags
end
def income_tag
@income_tag ||= tags.find { |t| t.name == "Emergency Fund" }
end
def income_category
@income_category ||= categories.find { |c| c.name == "Income" }
end
end

3
app/models/depository.rb Normal file
View File

@@ -0,0 +1,3 @@
class Depository < ApplicationRecord
include Accountable
end

View File

@@ -1,29 +1,29 @@
class ExchangeRate < ApplicationRecord
include Provided
validates :base_currency, :converted_currency, presence: true
validates :from_currency, :to_currency, :date, :rate, presence: true
class << self
def find_rate(from:, to:, date:)
find_by \
base_currency: Money::Currency.new(from).iso_code,
converted_currency: Money::Currency.new(to).iso_code,
def find_rate(from:, to:, date:, cache: true)
result = find_by \
from_currency: from,
to_currency: to,
date: date
result || fetch_rate_from_provider(from:, to:, date:, cache:)
end
def find_rate_or_fetch(from:, to:, date:)
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:).tap(&:save!)
end
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
missing_dates = all_dates - existing_dates
def get_rate_series(from, to, date_range)
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
end
if missing_dates.any?
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
end
def convert(value:, from:, to:, date:)
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date:)
raise "Conversion from: #{from} to: #{to} on: #{date} not found" unless rate
value * rate.rate
rates
end
end
end

View File

@@ -1,23 +1,38 @@
module ExchangeRate::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_rate_from_provider(from:, to:, date:)
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
return [] unless exchange_rates_provider.present?
dates.map do |date|
fetch_rate_from_provider from:, to:, date:, cache:
end.compact
end
def fetch_rate_from_provider(from:, to:, date:, cache: false)
return nil unless exchange_rates_provider.present?
response = exchange_rates_provider.fetch_exchange_rate \
from: Money::Currency.new(from).iso_code,
to: Money::Currency.new(to).iso_code,
from: from,
to: to,
date: date
if response.success?
ExchangeRate.new \
base_currency: from,
converted_currency: to,
rate = ExchangeRate.new \
from_currency: from,
to_currency: to,
rate: response.rate,
date: date
rate.save! if cache
rate
else
raise response.error
nil
end
end
end

View File

@@ -2,23 +2,25 @@ class Family < ApplicationRecord
has_many :users, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
has_many :transactions, through: :accounts
has_many :entries, through: :accounts
has_many :imports, through: :accounts
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
.select(
"account_balances.currency",
"account_balances.date",
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
)
.group("account_balances.date, account_balances.currency")
.order("account_balances.date")
.where("account_balances.currency = ?", self.currency)
.select(
"account_balances.currency",
"account_balances.date",
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
)
.group("account_balances.date, account_balances.currency")
.order("account_balances.date")
query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin
query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end
@@ -33,16 +35,18 @@ class Family < ApplicationRecord
def snapshot_account_transactions
period = Period.last_30_days
results = accounts.active.joins(:transactions)
.select(
"accounts.*",
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
)
.where("transactions.date >= ?", period.date_range.begin)
.where("transactions.date <= ?", period.date_range.end)
.group("id")
.to_a
results = accounts.active.joins(:entries)
.select(
"accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
)
.where("account_entries.date >= ?", period.date_range.begin)
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.marked_as_transfer = ?", false)
.where("account_entries.entryable_type = ?", "Account::Transaction")
.group("id")
.to_a
results.each do |r|
r.define_singleton_method(:savings_rate) do
@@ -58,7 +62,8 @@ class Family < ApplicationRecord
end
def snapshot_transactions
rolling_totals = Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
candidate_entries = entries.account_transactions.without_transfers
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = []
income = []
@@ -87,23 +92,29 @@ class Family < ApplicationRecord
}
end
def effective_start_date
accounts.active.joins(:balances).minimum("account_balances.date") || Date.current
end
def net_worth
assets - liabilities
end
def assets
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def liabilities
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def sync_accounts
accounts.each { |account| account.sync_later if account.can_sync? }
def sync(start_date: nil)
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

@@ -38,7 +38,7 @@ class Import < ApplicationRecord
end
def get_selected_header_for_field(field)
column_mappings&.dig(field) || field.key
column_mappings&.dig(field.key) || field.key
end
def update_csv!(row_idx:, col_idx:, value:)
@@ -56,6 +56,8 @@ class Import < ApplicationRecord
end
end
self.account.sync
update!(status: "complete")
rescue => e
update!(status: "failed")
@@ -109,7 +111,7 @@ class Import < ApplicationRecord
end
def generate_transactions
transactions = []
transaction_entries = []
category_cache = {}
tag_cache = {}
@@ -122,20 +124,19 @@ class Import < ApplicationRecord
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
end
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name) if category_name.present?
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
txn = account.transactions.build \
entry = account.entries.build \
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
date: Date.iso8601(row["date"]),
category: category,
tags: tags,
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
currency: account.currency
currency: account.currency,
amount: BigDecimal(row["amount"]) * -1,
entryable: Account::Transaction.new(category: category, tags: tags)
transactions << txn
transaction_entries << entry
end
transactions
transaction_entries
end
def create_expected_fields
@@ -146,15 +147,18 @@ class Import < ApplicationRecord
name_field = Import::Field.new \
key: "name",
label: "Name"
label: "Name",
is_optional: true
category_field = Import::Field.new \
key: "category",
label: "Category"
label: "Category",
is_optional: true
tags_field = Import::Field.new \
key: "tags",
label: "Tags"
label: "Tags",
is_optional: true
amount_field = Import::Field.new \
key: "amount",

View File

@@ -15,12 +15,17 @@ class Import::Field
attr_reader :key, :label, :validator
def initialize(key:, label:, validator: nil)
def initialize(key:, label:, is_optional: false, validator: nil)
@key = key.to_s
@label = label
@is_optional = is_optional
@validator = validator
end
def optional?
@is_optional
end
def define_validator(validator = nil, &block)
@validator = validator || block
end

View File

@@ -0,0 +1,7 @@
class Institution < ApplicationRecord
belongs_to :family
has_many :accounts, dependent: :nullify
has_one_attached :logo
scope :alphabetically, -> { order(name: :asc) }
end

47
app/models/investment.rb Normal file
View File

@@ -0,0 +1,47 @@
class Investment < ApplicationRecord
include Accountable
SUBTYPES = [
[ "Brokerage", "brokerage" ],
[ "Pension", "pension" ],
[ "Retirement", "retirement" ],
[ "401(k)", "401k" ],
[ "529 plan", "529_plan" ],
[ "Health Savings Account", "hsa" ],
[ "Mutual Fund", "mutual_fund" ],
[ "Roth IRA", "roth_ira" ],
[ "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

3
app/models/loan.rb Normal file
View File

@@ -0,0 +1,3 @@
class Loan < ApplicationRecord
include Accountable
end

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