Compare commits

...

199 Commits

Author SHA1 Message Date
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
Zach Gollwitzer
8f356656fc Bump to v0.1.0-alpha.3 (#806) 2024-05-24 14:24:03 -04:00
Jakub Kottnauer
6e59fdb369 Add tag preview when importing (#800) 2024-05-24 10:39:24 -04:00
Zach Gollwitzer
457247da8e Create tagging system (#792)
* Repro

* Fix

* Update signage

* Create tagging system

* Add tags to transaction imports

* Build tagging UI

* Cleanup

* More cleanup
2024-05-23 08:09:33 -04:00
Zach Gollwitzer
41c991384a Fix duplicate category creation on import (#791)
* Repro

* Fix

* Update signage
2024-05-22 10:02:03 -04:00
Jakub Kottnauer
77f166a5f8 Ignore empty categories while importing (#789)
* Ignore empty categories while importing

* Review fixes
2024-05-22 08:12:56 -04:00
Jakub Kottnauer
ac27a1c87f Move category dropdown menu content into a turbo frame (#782)
* Move category dropdown menu content into a turbo frame

* Fix lint

* Review fixes

* Cleanup

* Review fixes

* Final cleanup

* Revert schema change
2024-05-22 06:31:25 -04:00
Jakub Kottnauer
32748b0632 Fix import crash with empty transaction name (#783) 2024-05-20 17:21:40 -04:00
Marco Kuper
444155c103 Fix issue with start_date not being set in account creation (#781) 2024-05-20 16:59:23 -04:00
Zach Gollwitzer
8654a98e6e Update feature-requests.yml
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-05-20 12:16:30 -04:00
Zach Gollwitzer
3dd67d3ed6 Merge remote-tracking branch 'origin/main' 2024-05-20 11:57:34 -04:00
Zach Gollwitzer
4efbb58197 Add feature request discussion template 2024-05-20 11:57:14 -04:00
Jakub Kottnauer
94345ddc3a Add migration to make all current users admins (#770) 2024-05-20 11:33:19 -04:00
Zach Gollwitzer
6212d57915 Update issue templates 2024-05-20 11:15:32 -04:00
dependabot[bot]
5f75e2e14f Bump rails from fb4300c to ed50b93 (#774)
Bumps [rails](https://github.com/rails/rails) from `fb4300c` to `ed50b93`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](fb4300ce19...ed50b93ebc)

---
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-20 11:01:09 -04:00
dependabot[bot]
55f7cb1bc2 Bump mocha from 2.2.0 to 2.3.0 (#771)
Bumps [mocha](https://github.com/freerange/mocha) from 2.2.0 to 2.3.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.2.0...v2.3.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-05-20 10:35:31 -04:00
dependabot[bot]
5ac3a808b2 Bump good_job from 3.28.2 to 3.28.3 (#773)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.28.2 to 3.28.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.28.2...v3.28.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-05-20 10:35:18 -04:00
Jakub Kottnauer
30c19b9d2e Show an error notification if account cannot be manually synced (#761) 2024-05-20 10:34:48 -04:00
Jakub Kottnauer
34811d8fd8 Fix currency when importing to foreign accounts (#762) 2024-05-20 09:55:45 -04:00
Zach Gollwitzer
5fa34b4111 Bump to v0.1.0-alpha.2 2024-05-17 18:20:19 -04:00
Zach Gollwitzer
22e6919eb5 Add simplecov (#760) 2024-05-17 18:12:31 -04:00
Zach Gollwitzer
ac46c0c5a9 Fix income summary totals (#759) 2024-05-17 17:50:49 -04:00
Zach Gollwitzer
0d0f766ca1 Fix auto upgrade logic (#758) 2024-05-17 17:25:15 -04:00
Zach Gollwitzer
ddf26cd5e5 Pin Rails to 7-2-stable (#757) 2024-05-17 13:47:30 -04:00
Jakub Kottnauer
31ef3d85f5 Fix CSV import preview crash on empty values (#756) 2024-05-17 10:55:15 -04:00
Moses Gathuku
1bbfdee463 Prefer family currency preference than default currenty (#755) 2024-05-17 09:30:01 -04:00
Zach Gollwitzer
45ae4a9737 CSV Transaction Imports (#708)
Introduces a basic CSV import module for bulk-importing account transactions.

Changes include:

- User can load a CSV
- User can configure the column mappings for a CSV
- Imported CSV shows invalid cells
- User can clean up their data directly in the UI
- User can see a preview of the import rows and confirm import
- Layout refactor + Import nav stepper
- System test stability improvements
2024-05-17 09:09:32 -04:00
Jakub Kottnauer
3d9ff3ad2a Add start balance to manual accounts (#735)
* Add start_balance to accounts

* Add tests

* Cleanup

* Refactor code and add tests

* Update physical cash demo account to be manual

* Do not populate start_balance in migration

* Cleanup

* Review fixes

* Revert calc change

* Update app/models/exchange_rate.rb

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Jakub Kottnauer <jk@jakubkottnauer.com>

* Add test

* Fix syncable bug and update csv tests

---------

Signed-off-by: Jakub Kottnauer <jk@jakubkottnauer.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-05-16 15:57:21 -04:00
Josh Pigford
daf7ff8ef4 Initial user should be an admin 2024-05-15 11:21:11 -05:00
dependabot[bot]
5ed2c47c20 Bump dotenv-rails from 3.1.1 to 3.1.2 (#743)
Bumps [dotenv-rails](https://github.com/bkeepers/dotenv) from 3.1.1 to 3.1.2.
- [Release notes](https://github.com/bkeepers/dotenv/releases)
- [Changelog](https://github.com/bkeepers/dotenv/blob/main/Changelog.md)
- [Commits](https://github.com/bkeepers/dotenv/compare/v3.1.1...v3.1.2)

---
updated-dependencies:
- dependency-name: dotenv-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-05-13 13:24:57 -04:00
dependabot[bot]
25a2156c8f Bump aws-sdk-s3 from 1.149.0 to 1.149.1 (#745)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.149.0 to 1.149.1.
- [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-05-13 13:24:33 -04:00
dependabot[bot]
9509a568ac Bump i18n-tasks from 1.0.13 to 1.0.14 (#746)
Bumps [i18n-tasks](https://github.com/glebm/i18n-tasks) from 1.0.13 to 1.0.14.
- [Release notes](https://github.com/glebm/i18n-tasks/releases)
- [Changelog](https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md)
- [Commits](https://github.com/glebm/i18n-tasks/compare/v1.0.13...v1.0.14)

---
updated-dependencies:
- dependency-name: i18n-tasks
  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-13 13:24:16 -04:00
João Friaça
d4857d9f5b Fixing typo in Render URL (#737)
Signed-off-by: João Friaça <41522991+friaca@users.noreply.github.com>
2024-05-13 13:23:34 -04:00
Duy Le
943972690e fix password resets (#741) 2024-05-13 13:23:24 -04:00
Ali Karbassi
b448446fbe Add pointer to form submit buttons. (#740)
Signed-off-by: Ali Karbassi <ali@karbassi.com>
2024-05-13 13:06:20 -04:00
Zach Gollwitzer
61fae96832 Update issue templates 2024-05-10 06:37:55 -04:00
Zach Gollwitzer
2aee8e3027 Improve self hosting docs (#732) 2024-05-10 06:10:23 -04:00
Zach Gollwitzer
fac995b87e Remove stale Redis dep (#731)
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-05-09 19:55:05 -04:00
Claude Ayitey
d240c59d7f Allow users to set port in env (#726)
* Add custom port option to env.example

* Update readme.

* Port in bin/dev looks for env var before defaulting to 3000

* Change port for mailer in dev environment.

* Revisions in dev and readme files.
2024-05-08 15:54:40 -04:00
Jakub Kottnauer
6b0ef3a471 Fix crypto account creation modal crash (#727) 2024-05-08 15:30:13 -04:00
Jakub Kottnauer
1108e45596 Change default valuation currency to that of the related account, not family (#728) 2024-05-08 15:29:56 -04:00
Zach Gollwitzer
9ede14d23b Fix multi-arch package tagging 2024-05-08 15:22:25 -04:00
Zach Gollwitzer
38d50fbb1e Fix parallel testing segfaults on M1 macs 2024-05-08 08:17:46 -04:00
Zach Gollwitzer
ee433ed7c8 Add ARM target for Docker package publishing (#725) 2024-05-07 18:00:02 -04:00
Zach Gollwitzer
79789bd696 Enable all currencies for preferences (#722) 2024-05-07 16:19:00 -04:00
Zach Gollwitzer
1c2950462f Full commit sha 2024-05-07 09:34:54 -04:00
Zach Gollwitzer
62b7ada5e2 Enable read permissions on published packages 2024-05-07 07:39:14 -04:00
dependabot[bot]
16e5ffaed8 Bump tailwindcss-rails from 2.5.0 to 2.6.0 (#717)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.5.0 to 2.6.0.
- [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.5.0...v2.6.0)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-06 19:12:48 -04:00
dependabot[bot]
2c1dcb8649 Bump ruby-lsp-rails from 0.3.5 to 0.3.6 (#719)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.5 to 0.3.6.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.5...v0.3.6)

---
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-05-06 19:11:44 -04:00
dependabot[bot]
b977d0f623 Bump pagy from 8.3.0 to 8.4.0 (#716)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.3.0 to 8.4.0.
- [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.3.0...8.4.0)

---
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-05-06 19:11:13 -04:00
Radu C. Martin
930dc26828 Self Hosting: Docker Compose setup and flow (#640)
* feat(self-hosting): add docker-compose and ghcr release workflow

* WIP: address review comments

* WIP: fix linting
2024-05-06 18:52:14 -04:00
Nidhi Sarvaiya
0616d3e2b7 Allow two decimal for value in transaction history (#711) 2024-05-04 12:36:36 -04:00
Nidhi Sarvaiya
9bae455f18 Added custom template to show unsupported browser error (#712) 2024-05-04 12:35:44 -04:00
Zach Gollwitzer
4f508cd151 Bump to Ruby 3.3.1 (#709)
* Bump to Ruby 3.3.1

* Update Dockerfile versions

* Omit system tests in CI

* Use `:test` adapter in test

* Remove redundant config
2024-05-03 08:22:19 -04:00
Claude Ayitey
9563ac6334 Dashboard empty view (#707)
* Added i18n for the Dashboard page.

* Add a check for blank. Empty returned an error since @accounts was nil.

* Remove subtitle when accounts are empty..

* Increased the padding-right value for the buttons. The sapce occupied by the icon made the button elements unbalanced.

* Add refactor as suggested by Zach (https://github.com/maybe-finance/maybe/pull/707#discussion_r1588506443)

* Extract empty state into a partial.

* Render new partial if no accounts exist.

* Moved translation text to shared directory.

* Fix padding-right on New account button.

* Lint fix

* i18n tasks

* Get tests passing with :test queue adapter

---------

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-05-03 08:11:31 -04:00
Zach Gollwitzer
75cdddc6ca Fix Merchants controller (#704)
* Add climate_control gem and test helper

* Replace ENV mods in upgrades test

* Replace ENV mods in registrations test

* Remove ENV references in hostings controller

* Update ENV refs in mailer test

* ActiveStorage cleanup

* Consolidate queue config so appropriate adapter runs in test environment

* Make test environment more explicit

* Centralize self hosting config

* Remove flaky system test

* Fix merchants controller actions
2024-05-02 13:24:23 -04:00
Zach Gollwitzer
5dfbba403a Test environment stability improvements (#703)
* Add climate_control gem and test helper

* Replace ENV mods in upgrades test

* Replace ENV mods in registrations test

* Remove ENV references in hostings controller

* Update ENV refs in mailer test

* ActiveStorage cleanup

* Consolidate queue config so appropriate adapter runs in test environment

* Make test environment more explicit

* Centralize self hosting config

* Remove flaky system test
2024-05-02 13:18:18 -04:00
Zach Gollwitzer
98df7ccb11 Deps cleanup (#702) 2024-05-02 10:18:06 -04:00
Jose Farias
4c5f8263bc Implement transaction category management (#688)
* Singularize "transaction" in transaction-nested paths

* Refactor category badge partial

* Let modal content define its width

* Add contectual menu to transactions index

* Add null_category helper

* Implement category edits

* Fix inline transaction category badges

* Fix typos in system test paths

* Add missing translations

* Add decoration to color select controller

* Wire up transaction category creation

* Fix indent in color-select-controller

* Add button for clearing category from transaction

* Implement category deletions

* Fix existing modal sizes

* Use null_category in a single place

* Remove anemic method in category deletion controller

* reassign_and_destroy -> reassign_transactions_then_destroy

* Fix i18n

* Remove destroy action from CategoriesController callbacks

* transactions_merchant -> transaction_merchant

* reassign_transactions_then_destroy -> replace_and_destroy

* Add transaction category CRUD tests

* Add presence check for transaction_id

* Check replacement_category_id presence

* Test Transaction::Category#replace_and_destroy!
2024-05-02 09:24:31 -04:00
Christian
dc024d63b0 Feature/profile image uploads (#687)
* Introduce ActiveStorage

* Add active storage related service gems

* Update storage.yml

* Install image processing gem
- sudo apt-get install libvips (required dependency)

* Set default active storage service

* Add profile image to user model

* Amend form to allow profile images to be saved, introduce stimulus controller.

* Purge image when form is blank

* Update markup/stimulus controller

* Add test for profile image uplaods

* Add profile image validation

* Use rails guide gem versions

* Use correct ERB syntax and make all storage options configurable

* Ensure form submits when user clears profile image

* Add profile image thumbnail method

* Extract profile image to a partial

* Updates env.example and storage.yml

* Fix bug with double form save

* Add profile image to the sidenav

* Update production config

* Fix ERB formatting

* normalize en.yml

* Handle non-square images

* Use pre-processing on thumbnail variant

* Resovle gemfile.lock issues

* Rubocop style changes

---------

Signed-off-by: Christian <47796704+crobbo@users.noreply.github.com>
Co-authored-by: Christian Robinson <christian@robbo.dev>
2024-04-30 13:38:33 -04:00
Josh Brown
19ee773d9b Add ability to delete Maybe account (#698)
* Build out user deactivation and purging workflows

* Add i18n translations for user deletion

* Add tests for user deletion

* Fix lint issue
2024-04-30 11:40:31 -04:00
dependabot[bot]
55cb1ae5bd Bump good_job from 3.28.0 to 3.28.2 (#692)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.28.0 to 3.28.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.0...v3.28.2)

---
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-04-29 16:44:40 -04:00
Thibaut Gorioux
6fdb8e8d69 Allow a self-hosted user to configure their SMTP settings directly from within the UI (#682)
* Add setting fields to model

* Allow to configure SMTP settings

* Normalize locales

* Cleanup locales

* Remove 'coming soon'

* fix test

* Reset credentials

* Reset development config

* Check smtp spelling

* Use post instead of get method

* TLS ENV variable is more descriptive

* Rework application mailer

* Follow rails convention for mailer action params

* Reset schema.rb to main

* Test WIP

* Add test for controller and mailer

* Move tests from controller to model

* Custom error message if settings are not all present

* Comment smtp config in development env

* Add default tls enabled value

* Rubocop

* Fix controller test

* Reset credentials

* Normalize locales

* Test

* fix test

* Fix application mailer test that fails randomly

* Error flash message instead of notice

* Rework application mailer tests
2024-04-29 16:44:24 -04:00
dependabot[bot]
f0480e7ab7 Bump pagy from 8.2.2 to 8.3.0 (#694)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.2.2 to 8.3.0.
- [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.2.2...8.3.0)

---
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-04-29 15:21:36 -04:00
dependabot[bot]
1a565137fb Bump tailwindcss-rails from 2.4.0 to 2.5.0 (#693)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.4.0 to 2.5.0.
- [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.4.0...v2.5.0)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-29 15:20:57 -04:00
dependabot[bot]
e65e61c974 Bump selenium-webdriver from 4.19.0 to 4.20.1 (#695)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.19.0 to 4.20.1.
- [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)

---
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-04-29 15:20:35 -04:00
dependabot[bot]
66c49a37ef Bump rails from d462fb5 to a94938f (#691)
Bumps [rails](https://github.com/rails/rails) from `d462fb5` to `a94938f`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](d462fb54b4...a94938f10c)

---
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-04-29 15:20:22 -04:00
Jakub Kottnauer
9549182462 Add Transaction Merchant management (#686)
* Add basid crud for merchant management

* Tweak UI and add localization

* Fix lint

* Add filtering by merchant

* Add tests

* Add stimulus controller to update avatar in merchant form

* Add line between merchant rows

* Change default merchant color

* Cleanup
2024-04-29 15:17:28 -04:00
Josh Brown
7f491f5064 Add trends to sidebar account list (#697) 2024-04-29 09:56:38 -04:00
Razvan Marescu
93953499a6 Fix profile page on fresh install (#684) 2024-04-27 08:59:02 -04:00
Zach Gollwitzer
e7fe1b5a4b Reorder settings nav and add translations (#680)
* Reorder settings nav and add translations

* Attempt to fix flaky system test
2024-04-25 16:31:12 -04:00
Zach Gollwitzer
8ea7b54fe8 Update self host settings page styles (#677)
* Update page styles

* Add new styles to self host settings page

* Update self hosting page title
2024-04-25 14:55:39 -04:00
Zach Gollwitzer
da5021b6b0 Clean up i18n translations (#676)
* Clean up i18n translations

* Normalize translations
2024-04-25 14:41:12 -04:00
Josh Brown
be21d2b4fd Refactor placeholder logo into common controller (#673)
This can be updated to redirect/pull from third party sources in future,
with the option of always falling back to the placeholder if there are
any failures.
2024-04-25 10:32:45 -04:00
Zach Gollwitzer
5a5f13b46b Add new settings profile and preferences pages (#672)
* Add new settings profile and preferences pages

* Fix lint errors
2024-04-25 07:54:56 -04:00
Jakub Kottnauer
ad4de99f1a Add partial account sync support (#653)
* Add partial account sync support

* Fix indentation

* Use historical balance as base when doing partial sync

* Simplify controller crud tests

* Fix linter errors
2024-04-24 15:55:52 -04:00
Josh Brown
b3f8ab78d9 Implement savings rate insight card (#670) 2024-04-24 10:02:22 -04:00
Josh Brown
461fa672ff Add income and spending insight cards to dashboard (#668)
* Generate time series for rolling 30 day sum of income and spending

* Highlight accounts with most income and spending in the last 30 days

* Aggregate chips after 3 top accounts in insight card

* Refactor aggregation filter

I think this is easier to read and understand whats happening at a
glance

* Refactor and tidy

* Use family currency for insight cards

* Further reduce risk of sql injection

* Fix lint

* Refactor rolling total queries

* Add test for transaction snapshot
2024-04-24 08:34:50 -04:00
Josh Brown
1f6e83ee91 Add pie chart for asset/debt allocation in dashboard view (#666)
* Add pie chart for asset/debt allocation in dashboard view

* Fix lint issue

* Fix z-index issue with tooltip under pie chart

* Fix spacing of dashboard charts
2024-04-23 12:05:18 -04:00
Mattia
8a29725562 Add transactions widget to dashboard page (#656) 2024-04-22 16:51:06 -04:00
Jose Farias
49b603f478 Flesh out D3 time series charts (#657)
* Reindent TimeSeries classes

* Fix spacing in time series tests

* Remove trend tests where current is nil

I think if we've gotten this far with a nil value for current, there's a data integrity problem.

If we allow this, we'll have to be very defensive in our code. Best to raise and fix early.

* Reindent Money class

* Refactor TimeSeries artifacts

* Use as_json in TimeSeries

* Bring back tests for trends where current is nil

* Bring back trend test

* Correctly enumerate trend test

* Use favorable_direction for trend_styles helper

* Make trend public in TimeSeries::Value

* Allow nil current values in trends

I think I might've gotten it wrong before, nils might appear in trends if values are unavailable for snapshots

* Clean up TimeSeries::Trend

* Skip trend values same class validations if any values are nil

* Refactor Money

* Remove object parsing in TimeSeries::Value

We're only every passing hashes

* Refactor trendline chart controller into a time series chart controller

* Replace trendline controller

* Implement empty state

* Port line-chart controller into time-series-chart

* Split out methods

* Group similar time series chart functionality

* Fix indicator color

* Fix empty state in time series chart

* Replace line-chart controller with time-series-chart controller

* Draw empty time series chart if less than 2 data points

* Fix favorable direction serialization

* Handle integers as well as money

* Fix favorable direction serialization

* Replace chart types with optional elements

* Prevent double-renders when displaying turbo caches of time series charts

* Remove ambiguities between time series and series data

* Improve time series chart property names

* Clean up tooltip template

* Match tooltip designs

* Apply trendline gradient

* Implement trendline split behavior

* Use same stroke width on all trend lines

* Sort time series data by date

* Support percentages

* Use data color for guideline circles

* Revert "Use data color for guideline circles"

This reverts commit f239a1e00f.

* Use expected defaults for time series chart

* Include day in time-series chart x-axis labels

* favorableDirection -> favorable_direction

* data -> datum where appropriate

* Hide change data in tooltip for percentages
2024-04-22 13:44:26 -04:00
dependabot[bot]
070084078a Bump rails from 6d3fd5b to d462fb5 (#665)
Bumps [rails](https://github.com/rails/rails) from `6d3fd5b` to `d462fb5`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](6d3fd5b98c...d462fb54b4)

---
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-04-22 09:40:37 -04:00
dependabot[bot]
594bd6282f Bump good_job from 3.27.4 to 3.28.0 (#663)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.27.4 to 3.28.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.27.4...v3.28.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-04-22 09:33:30 -04:00
dependabot[bot]
4d255e5670 Bump pagy from 8.1.2 to 8.2.2 (#662)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.1.2 to 8.2.2.
- [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.1.2...8.2.2)

---
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-04-22 09:33:21 -04:00
dependabot[bot]
11d00a648a Bump sentry-rails from 5.17.2 to 5.17.3 (#661)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.17.2 to 5.17.3.
- [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.2...5.17.3)

---
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-04-22 09:33:03 -04:00
dependabot[bot]
ce1840d846 Bump redis from 5.1.0 to 5.2.0 (#660)
Bumps [redis](https://github.com/redis/redis-rb) from 5.1.0 to 5.2.0.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: redis
  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-04-22 09:32:52 -04:00
Jose Farias
fc3ade392a Refactor TimeSeries artifacts (#651)
* Reindent TimeSeries classes

* Fix spacing in time series tests

* Remove trend tests where current is nil

I think if we've gotten this far with a nil value for current, there's a data integrity problem.

If we allow this, we'll have to be very defensive in our code. Best to raise and fix early.

* Reindent Money class

* Refactor TimeSeries artifacts

* Use as_json in TimeSeries

* Bring back tests for trends where current is nil

* Bring back trend test

* Correctly enumerate trend test

* Use favorable_direction for trend_styles helper

* Make trend public in TimeSeries::Value

* Allow nil current values in trends

I think I might've gotten it wrong before, nils might appear in trends if values are unavailable for snapshots

* Clean up TimeSeries::Trend

* Skip trend values same class validations if any values are nil

* Refactor Money

* Remove object parsing in TimeSeries::Value

We're only every passing hashes
2024-04-22 08:30:42 -04:00
Zach Gollwitzer
fe2a2ac3f9 Update issue templates 2024-04-20 08:28:00 -04:00
Thibaut Gorioux
5be1ced19e Shared money input should respond to change in currency (#654)
* Add step method to currency

* Change amount placeholder and step, when currency select change

* Lint

* Add test with auth

* Extract request to specific service
2024-04-20 08:07:06 -04:00
Zach Gollwitzer
c46662c99f Add apply button, improve transaction filters (#655)
* Add apply button, improve transaction filters

* Remove temp log

* Fix lint errors
2024-04-19 12:03:16 -04:00
Zach Gollwitzer
0277bc94f3 Small redesign of transaction filters (#650) 2024-04-18 17:05:37 -04:00
Jakub Kottnauer
0a1fa525d5 Fix modal height in Safari (#648) 2024-04-18 16:55:56 -04:00
Zach Gollwitzer
f5f624881f Add placeholders for dashboard features (#642)
* Add placeholders for new dashboard

* Fix tests and lint errors
2024-04-18 10:32:36 -04:00
Zach Gollwitzer
4708e85da3 Update issue templates 2024-04-18 08:34:50 -04:00
Zach Gollwitzer
9bda7efc3f New Settings Menu, Routes and Controllers Organization (#641)
* Add new settings routes and controllers

* Add new settings view, restructure controllers and routes

* Fix lint errors
2024-04-18 07:56:51 -04:00
Harshit Chaudhary
39d57a167e Fixed Data Access Restriction (#636)
Co-authored-by: Harshit Chaudhary <harshit.chaudhary@procol.in>
2024-04-16 15:58:53 -04:00
Jose Farias
cd8d741fe1 Add transaction modal flow (#633)
* Add transaction modal flow

* Preserve decimals when creating transactions
2024-04-16 14:44:31 -04:00
Thibaut Gorioux
a22c7a0e9c Add persistent notification with dismiss and action button (#611)
* Update notification partial

* Update locals args

* Lint

* Move content to body in notification helper

* Avoid dynamic Tailwind class

* Styling

* Add notification to localization file

* Lint

* Normalize locales

* Auto dismiss by default
2024-04-16 13:33:51 -04:00
dependabot[bot]
5516b03b6e Bump rails from bad7ff1 to 6d3fd5b (#626)
Bumps [rails](https://github.com/rails/rails) from `bad7ff1` to `6d3fd5b`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](bad7ff1664...6d3fd5b98c)

---
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-04-15 12:04:59 -04:00
dependabot[bot]
3672835ba1 Bump mocha from 2.1.0 to 2.2.0 (#617)
Bumps [mocha](https://github.com/freerange/mocha) from 2.1.0 to 2.2.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.1.0...v2.2.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-04-15 11:35:36 -04:00
dependabot[bot]
be288afcd4 Bump pagy from 8.0.2 to 8.1.2 (#618)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.0.2 to 8.1.2.
- [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.0.2...8.1.2)

---
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-04-15 11:35:29 -04:00
dependabot[bot]
81115a9bed Bump tailwindcss-rails from 2.3.0 to 2.4.0 (#620)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.3.0 to 2.4.0.
- [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.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 11:35:22 -04:00
dependabot[bot]
eee07a4d6c Bump sentry-ruby from 5.17.2 to 5.17.3 (#621)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.17.2 to 5.17.3.
- [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.2...5.17.3)

---
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-04-15 11:35:04 -04:00
Pieter Beulque
fc631e698d Allow 2 decimals when adding a balance (#614) 2024-04-15 11:27:39 -04:00
511 changed files with 13166 additions and 4522 deletions

View File

@@ -1,27 +1,5 @@
# ARG RUBY_VERSION=3.3.0
# FROM ruby:${RUBY_VERSION}-slim-bullseye
# TODO - Uncomment the lines above when 3.3.1 is released.
# This is a temporary fix for a bug found here (https://stackoverflow.com/questions/77725755/segmentation-fault-during-rails-assetsprecompile-on-apple-silicon-m3-with-rub)
FROM debian:bullseye-slim as base
# Install dependencies for building Ruby
RUN apt-get update && apt-get install -y build-essential wget autoconf
# Install ruby-install for installing Ruby
RUN wget https://github.com/postmodern/ruby-install/releases/download/v0.9.3/ruby-install-0.9.3.tar.gz \
&& tar -xzvf ruby-install-0.9.3.tar.gz \
&& cd ruby-install-0.9.3/ \
&& make install
# Install Ruby 3.3.0 with the https://github.com/ruby/ruby/pull/9371 patch
RUN ruby-install -p https://github.com/ruby/ruby/pull/9371.diff ruby 3.3.0
# Make the Ruby binary available on the PATH
ENV PATH="/opt/rubies/ruby-3.3.0/bin:${PATH}"
# End TODO
ARG RUBY_VERSION=3.3.1
FROM ruby:${RUBY_VERSION}-slim-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \

View File

@@ -1,3 +1,7 @@
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=
# Exchange Rate API
# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
SYNTH_API_KEY=
@@ -9,7 +13,10 @@ SMTP_ADDRESS=
SMTP_PORT=465
SMTP_USERNAME=
SMTP_PASSWORD=
TLS=true
SMTP_TLS_ENABLED=true
# Email Configuration
EMAIL_SENDER=
# Database Configuration
DB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer
@@ -36,6 +43,13 @@ SELF_HOSTING_ENABLED=false
# `localhost` (or unset) is used for local development and testing
HOSTING_PLATFORM=localhost
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
SECRET_KEY_BASE=secret-value
# Disable enforcing SSL connections
# DISABLE_SSL=true
# ======================================================================================================
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
# ======================================================================================================
@@ -44,7 +58,7 @@ HOSTING_PLATFORM=localhost
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
#
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
UPGRADES_MODE=manual # `manual` or `auto`
UPGRADES_TARGET=release # `release` or `commit`
@@ -53,6 +67,22 @@ UPGRADES_TARGET=release # `release` or `commit`
# Git Repository Module - responsible for fetching latest commit data for upgrades
# ======================================================================================================
#
GITHUB_REPO_OWNER=maybe-finance
GITHUB_REPO_OWNER=maybe-finance
GITHUB_REPO_NAME=maybe
GITHUB_REPO_BRANCH=main
# ======================================================================================================
# Active Storage Configuration - responsible for storing file uploads
# ======================================================================================================
#
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
# * Set the appropriate environment variables to use these services.
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
#
# Amazon S3
# ==========
# ACTIVE_STORAGE_SERVICE=amazon
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_REGION= # defaults to `us-east-1` if not set
# S3_BUCKET=

View File

@@ -0,0 +1,22 @@
title: Feature Request
body:
- type: markdown
attributes:
value: |
Thanks for your interest in Maybe! Please follow the template below to submit your feature request. You can visit our [roadmap](https://github.com/orgs/maybe-finance/projects/13) to see what's currently planned.
- type: textarea
attributes:
label: Describe the feature
description: Provide a clear and concise description of the feature you would like.
validations:
required: true
- type: textarea
attributes:
label: Why is this feature important?
description: Tell us what specific problem(s) this feature solves for you or other users.
validations:
required: true
- type: textarea
attributes:
label: Additional context, screenshots, and relevant links
description: Provide additional info to help us evaluate whether this feature is a good fit for the product.

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,27 @@
---
name: Bug report
about: Create a report to help us improve
title: 'Bug: '
labels: ":bug: Bug"
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.
**Additional context**
Add any other context about the problem here.

21
.github/ISSUE_TEMPLATE/other.md vendored Normal file
View File

@@ -0,0 +1,21 @@
---
name: Other
about: All other issues
title: ''
labels: ''
assignees: ''
---
**PLEASE READ before opening an issue:**
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
----------------------
**Is this issue related to a problem? Please describe.**
**Describe the work that needs to be done to address this issue**
**Additional context**

View File

@@ -1,9 +1,7 @@
name: CI
on:
pull_request:
push:
branches: [main]
workflow_call:
jobs:
scan_ruby:
@@ -61,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
@@ -71,12 +73,6 @@ jobs:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
# redis:
# image: redis
# ports:
# - 6379:6379
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
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
@@ -90,17 +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
# REDIS_URL: redis://localhost:6379/0
- name: DB setup and smoke test
run: |
bin/rails db:create
bin/rails db:schema:load
bin/rails test:all
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

73
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Publish Docker image
on:
push:
tags:
- 'v*'
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
jobs:
ci:
uses: ./.github/workflows/ci.yml
build:
name: Build docker image
needs: [ ci ]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=auto
tags: |
type=sha,format=long
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push Docker image
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,linux/arm/v7
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app

5
.gitignore vendored
View File

@@ -50,3 +50,8 @@
# Ignore .devcontainer files
compose-dev.yaml
# Ignore GCP keyfile
gcp-storage-keyfile.json
coverage

View File

@@ -1 +1 @@
3.3.0
3.3.1

View File

@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.3.0
ARG RUBY_VERSION=3.3.1
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
# Rails app lives here

31
Gemfile
View File

@@ -3,11 +3,10 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", github: "rails/rails", branch: "main"
gem "rails", github: "rails/rails", branch: "7-2-stable"
# Drivers
gem "pg", "~> 1.5"
gem "redis", ">= 4.0.1"
# Deployment
gem "puma", ">= 5.0"
@@ -26,37 +25,41 @@ gem "turbo-rails"
# Background Jobs
gem "good_job"
# Search
gem "ransack"
# Error logging
gem "stackprof"
gem "sentry-ruby"
gem "sentry-rails"
gem "rails-settings-cached"
gem "octokit"
# Active Storage
gem "aws-sdk-s3", require: false
gem "image_processing", ">= 1.2"
# Other
gem "bcrypt", "~> 3.1.7"
gem "inline_svg"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "bcrypt", "~> 3.1"
gem "faraday"
gem "faraday-retry"
gem "inline_svg"
gem "octokit"
gem "pagy"
gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "csv"
group :development, :test do
gem "debug", platforms: %i[ mri windows ]
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
gem "dotenv-rails"
gem "letter_opener"
gem "i18n-tasks"
gem "erb_lint"
end
group :development do
gem "web-console"
gem "dotenv-rails"
gem "hotwire-livereload"
gem "letter_opener"
gem "ruby-lsp-rails"
gem "web-console"
gem "faker"
end
group :test do
@@ -65,4 +68,6 @@ group :test do
gem "mocha"
gem "vcr"
gem "webmock"
gem "climate_control"
gem "simplecov", require: false
end

View File

@@ -1,38 +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/rails/rails.git
revision: bad7ff1664fb05cc227d0386ee3cbe4c292efe05
branch: main
revision: 8075866ae8dfee76e1c6099b9eea6dcb7df70803
branch: 7-2-stable
specs:
actioncable (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actioncable (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
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.beta2)
actionpack (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
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.beta2)
actionpack (= 7.2.0.beta2)
actionview (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
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.beta2)
actionview (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@@ -41,61 +41,62 @@ 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.beta2)
actionpack (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionview (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
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.beta2)
activesupport (= 7.2.0.beta2)
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.beta2)
activesupport (= 7.2.0.beta2)
activerecord (7.2.0.beta2)
activemodel (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
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.beta2)
actionpack (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
marcel (~> 1.0)
activesupport (7.2.0.alpha)
activesupport (7.2.0.beta2)
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)
minitest (>= 5.1, < 5.22.0)
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.beta2)
actioncable (= 7.2.0.beta2)
actionmailbox (= 7.2.0.beta2)
actionmailer (= 7.2.0.beta2)
actionpack (= 7.2.0.beta2)
actiontext (= 7.2.0.beta2)
actionview (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activemodel (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
bundler (>= 1.15.0)
railties (= 7.2.0.alpha)
railties (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
irb
railties (= 7.2.0.beta2)
railties (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
@@ -107,22 +108,38 @@ GEM
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.952.0)
aws-sdk-core (3.201.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
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.5)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
better_html (2.0.2)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.1.7)
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.18.3)
msgpack (~> 1.2)
brakeman (6.1.2)
racc
builder (3.2.4)
builder (3.3.0)
capybara (3.40.0)
addressable
matrix
@@ -133,19 +150,22 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (5.0.0)
concurrent-ruby (1.2.3)
climate_control (1.2.0)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
csv (3.3.0)
date (3.3.4)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.0)
dotenv-rails (3.1.0)
dotenv (= 3.1.0)
docile (1.4.0)
dotenv (3.1.2)
dotenv-rails (3.1.2)
dotenv (= 3.1.2)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.5.0)
@@ -155,38 +175,41 @@ 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.1)
i18n (>= 1.8.11, < 2)
faraday (2.9.2)
faraday-net_http (>= 2.0, < 3.2)
faraday-net_http (3.1.0)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
ffi (1.16.3)
fugit (1.10.1)
et-orbi (~> 1, >= 1.2.7)
fugit (1.11.0)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (3.27.4)
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.0.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.3.2)
hotwire-livereload (1.4.0)
actioncable (>= 6.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
i18n (1.14.4)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.13)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
better_html (>= 1.0, < 3.0)
erubi
highline (>= 2.0.0)
i18n
@@ -194,6 +217,9 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.1)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -202,12 +228,13 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2)
irb (1.12.0)
rdoc
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.7.1)
jmespath (1.6.2)
json (2.7.2)
language_server-protocol (3.17.0.3)
launchy (3.0.0)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
letter_opener (1.10.0)
@@ -215,6 +242,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)
@@ -225,14 +253,15 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (4.12.0)
mini_mime (1.1.5)
minitest (5.21.2)
mocha (2.1.0)
minitest (5.24.1)
mocha (2.4.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
net-http (0.4.1)
uri
net-imap (0.4.10)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@@ -241,43 +270,42 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.1)
nokogiri (1.16.3-aarch64-linux)
nio4r (2.7.3)
nokogiri (1.16.6-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.3-arm-linux)
nokogiri (1.16.6-arm-linux)
racc (~> 1.4)
nokogiri (1.16.3-arm64-darwin)
nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.3-x86-linux)
nokogiri (1.16.6-x86-linux)
racc (~> 1.4)
nokogiri (1.16.3-x86_64-darwin)
nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.3-x86_64-linux)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
octokit (8.1.0)
base64
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.0.2)
pagy (8.6.3)
parallel (1.24.0)
parser (3.3.0.5)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
prism (0.24.0)
propshaft (0.8.0)
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.4)
public_suffix (5.1.0)
puma (6.4.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.7.3)
rack (3.0.10)
racc (1.8.0)
rack (3.1.6)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -292,7 +320,7 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails-i18n (7.0.8)
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.4)
@@ -300,24 +328,19 @@ GEM
railties (>= 5.0.0)
rainbow (3.1.1)
rake (13.2.1)
ransack (4.1.1)
activerecord (>= 6.1.5)
activesupport (>= 6.1.5)
i18n
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rdoc (6.6.3.1)
rbs (3.5.1)
logger
rdoc (6.7.0)
psych (>= 4.0.0)
redis (5.1.0)
redis-client (>= 0.17.0)
redis-client (0.19.1)
connection_pool
regexp_parser (2.9.0)
reline (0.5.0)
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
rexml (3.2.6)
rubocop (1.60.2)
rexml (3.3.0)
strscan
rubocop (1.63.5)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -325,69 +348,79 @@ GEM
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
rubocop-minitest (0.34.5)
rubocop (>= 1.39, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-performance (1.20.2)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.21.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-rails (2.23.1)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.25.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.16.2)
ruby-lsp (0.17.4)
language_server-protocol (~> 3.17.0)
prism (>= 0.22.0, < 0.25)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.5)
ruby-lsp (>= 0.16.0, < 0.17.0)
sorbet-runtime (>= 0.5.9897)
ruby-lsp-rails (0.3.8)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
ffi (~> 1.12)
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.19.0)
selenium-webdriver (4.22.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.2)
sentry-rails (5.18.1)
railties (>= 5.0)
sentry-ruby (~> 5.17.2)
sentry-ruby (5.17.2)
sentry-ruby (~> 5.18.1)
sentry-ruby (5.18.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11332)
sorbet-runtime (0.5.11473)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.0)
tailwindcss-rails (2.3.0)
railties (>= 6.0.0)
tailwindcss-rails (2.3.0-aarch64-linux)
railties (>= 6.0.0)
tailwindcss-rails (2.3.0-arm-linux)
railties (>= 6.0.0)
tailwindcss-rails (2.3.0-arm64-darwin)
railties (>= 6.0.0)
tailwindcss-rails (2.3.0-x86_64-darwin)
railties (>= 6.0.0)
tailwindcss-rails (2.3.0-x86_64-linux)
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.1)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
@@ -407,7 +440,7 @@ 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)
@@ -418,7 +451,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.13)
zeitwerk (2.6.16)
PLATFORMS
aarch64-linux
@@ -429,17 +462,23 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
bcrypt (~> 3.1.7)
aws-sdk-s3
bcrypt (~> 3.1)
bootsnap
brakeman
capybara
climate_control
csv
debug
dotenv-rails
erb_lint
faker
faraday
faraday-retry
good_job
hotwire-livereload
i18n-tasks
image_processing (>= 1.2)
importmap-rails
inline_svg
letter_opener
@@ -452,13 +491,12 @@ DEPENDENCIES
puma (>= 5.0)
rails!
rails-settings-cached
ransack
redis (>= 4.0.1)
rubocop-rails-omakase
ruby-lsp-rails
selenium-webdriver
sentry-rails
sentry-ruby
simplecov
stackprof
stimulus-rails
tailwindcss-rails
@@ -469,7 +507,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.0p0
ruby 3.3.1p55
BUNDLED WITH
2.5.5
2.5.9

View File

@@ -3,38 +3,46 @@
# Maybe: The OS for your personal finances
<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
<b>Get
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
_If you're looking for the previous React codebase, you can find it at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
_If you're looking for the previous React codebase, you can find it
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
## Backstory
We spent the better part of 2021/2022 building a personal finance + wealth management app called, Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
We spent the better part of 2021/2022 building a personal finance + wealth
management app called, Maybe. Very full-featured, including an "Ask an Advisor"
feature which connected users with an actual CFP/CFA to help them with their
finances (all included in your subscription).
The business end of things didn't work out, and so we shut things down mid-2023.
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc.).
We spent the better part of $1,000,000 building the app (employees +
contractors, data providers/services, infrastructure, etc.).
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
We're now reviving the product as a fully open-source project. The goal is to
let you run the app yourself, for free, and use it to manage your own finances
and eventually offer a hosted version of the app for a small monthly fee.
## Self Hosting
## Maybe Hosting
You can find [detailed setup guides for self hosting here](docs/self-hosting.md).
There are 3 primary ways to use the Maybe app:
### One-Click Render deploy (recommended)
<a href="https://render.com/deploy?repo=https://github.com/maybe-finance/maybe">
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" />
</a>
1. Click the button above
2. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md)
1. Managed (easiest) - _coming soon..._
2. [One-click deploy](docs/hosting/one-click-deploy.md)
3. [Self-host with Docker](docs/hosting/docker.md)
## 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 (see `Gemfile`)
- Ruby 3.3.1
- PostgreSQL >9.3 (ideally, latest stable version)
After cloning the repo, the basic setup commands are:
@@ -49,7 +57,8 @@ bin/dev
rake demo_data:reset
```
And visit http://localhost:3000 to see the app. You can use the following credentials to log in (generated by DB seed):
And visit http://localhost:3000 to see the app. You can use the following
credentials to log in (generated by DB seed):
- Email: `user@maybe.local`
- Password: `password`
@@ -60,38 +69,52 @@ For further instructions, see guides below.
If you'd like multi-currency support, there are a few extra steps to follow.
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe product and the free plan is sufficient for basic multi-currency support.
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe
product and the free plan is sufficient for basic multi-currency support.
2. Add your API key to your `.env` file.
### Setup Guides
#### Dev Container (optional)
This is 100% optional and meant for devs who don't want to worry about installing requirements manually for their platform. You can follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more about Dev Containers.
This is 100% optional and meant for devs who don't want to worry about
installing requirements manually for their platform. You can
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
to learn more about Dev Containers.
If you run into `could not connect to server` errors, you may need to change your `.env`'s `DB_HOST` environment variable value to `db` to point to the Postgres container.
If you run into `could not connect to server` errors, you may need to change
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
Postgres container.
#### Mac
Please visit our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
Please visit
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
#### Linux
Please visit our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
Please visit
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
#### Windows
Please visit our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
Please visit
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
### Testing Emails
In development, we use `letter_opener` to automatically open emails in your browser. When an email sends locally, a new browser tab will open with a preview.
In development, we use `letter_opener` to automatically open emails in your
browser. When an email sends locally, a new browser tab will open with a
preview.
## Contributing
Before contributing, you'll likely find it helpful to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
Before contributing, you'll likely find it helpful
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
Once you've done that, please visit our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) to get started!
Once you've done that, please visit
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
to get started!
## Repo Activity
@@ -99,4 +122,6 @@ Once you've done that, please visit our [contributing guide](https://github.com/
## Copyright & license
Maybe is distributed under an [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE). "Maybe" is a trademark of Maybe Finance, Inc.
Maybe is distributed under
an [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE). "
Maybe" is a trademark of Maybe Finance, Inc.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -7,53 +7,76 @@
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 bg-white rounded-xl shadow-sm;
@apply focus-within:shadow-none focus-within:border-gray-900 focus-within:ring-4 focus-within:ring-gray-100;
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
@apply p-3 pb-0 block text-sm font-medium opacity-50;
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
}
.form-field__input {
@apply p-3 w-full bg-transparent border-none opacity-100;
@apply focus:outline-none focus:ring-0 focus:opacity-100;
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:opacity-50;
}
.form-field__radio {
@apply text-gray-900;
}
.form-field__submit {
@apply w-full p-3 text-center text-white bg-black rounded-lg 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");
}
.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,105 @@
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 new
@entry = @account.entries.build.tap do |entry|
if params[:entryable_type]
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
else
entry.entryable = Account::Valuation.new
end
end
end
def create
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
if @entry.save
@entry.sync_account_later
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
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,10 @@
class Account::LogosController < ApplicationController
def show
@account = Current.family.accounts.find(params[:account_id])
render_placeholder
end
def render_placeholder
render formats: :svg
end
end

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[:error] = @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,50 +1,62 @@
class AccountsController < ApplicationController
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
@institutions = Current.family.institutions
@accounts = Current.family.accounts.ungrouped.alphabetically
end
def summary
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@liability_series = snapshot[:liability_series]
@accounts = Current.family.accounts
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
end
def list
end
def new
@account = Account.new(
balance: nil,
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
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: t(".success") }),
turbo_stream.replace("account_#{@account.id}", partial: "accounts/account", locals: { account: @account })
]
end
end
else
render "edit", status: :unprocessable_entity
end
@account.update! account_params.except(:accountable_type, :balance)
@account.update_balance!(account_params[:balance]) if account_params[:balance]
@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))
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
if @account.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
@@ -53,23 +65,25 @@ class AccountsController < ApplicationController
end
def sync
@account.sync_later if @account.can_sync?
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: t(".success") })
end
unless @account.syncing?
@account.sync_later
end
redirect_to account_path(@account), notice: t(".success")
end
def sync_all
Current.family.accounts.active.sync
redirect_back_or_to accounts_path, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :currency, :subtype, :is_active)
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
end

View File

@@ -2,20 +2,8 @@ class ApplicationController < ActionController::Base
include 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?
if Current.user.last_login_at.nil? || Current.user.last_login_at.before?(Date.current.beginning_of_day)
Current.family.sync_accounts
end
end
end

View File

@@ -0,0 +1,47 @@
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.categories.alphabetically
end
def new
@category = Current.family.categories.new color: Category::COLORS.sample
end
def create
Category.transaction do
category = Current.family.categories.create!(category_params)
@transaction.update!(category_id: category.id) if @transaction
end
redirect_back_or_to transactions_path, notice: t(".success")
end
def edit
end
def update
@category.update! category_params
redirect_back_or_to transactions_path, notice: t(".success")
end
private
def set_category
@category = Current.family.categories.find(params[:id])
end
def set_transaction
if params[:transaction_id].present?
@transaction = Current.family.transactions.find(params[:transaction_id])
end
end
def category_params
params.require(:category).permit(:name, :color)
end
end

View File

@@ -0,0 +1,26 @@
class Category::DeletionsController < ApplicationController
layout "with_sidebar"
before_action :set_category
before_action :set_replacement_category, only: :create
def new
end
def create
@category.replace_and_destroy! @replacement_category
redirect_back_or_to transactions_path, notice: t(".success")
end
private
def set_category
@category = Current.family.categories.find(params[:category_id])
end
def set_replacement_category
if params[:replacement_category_id].present?
@replacement_category = Current.family.categories.find(params[:replacement_category_id])
end
end
end

View File

@@ -0,0 +1,22 @@
class Category::DropdownsController < ApplicationController
before_action :set_from_params
def show
@categories = categories_scope.to_a.excluding(@selected_category).prepend(@selected_category).compact
end
private
def set_from_params
if params[:category_id]
@selected_category = categories_scope.find(params[:category_id])
end
if params[:transaction_id]
@transaction = Current.family.transactions.find(params[:transaction_id])
end
end
def categories_scope
Current.family.categories.alphabetically
end
end

View File

@@ -7,6 +7,6 @@ module SelfHostable
private
def self_hosted?
ENV["SELF_HOSTING_ENABLED"] == "true"
Rails.configuration.app_mode.self_hosted?
end
end

View File

@@ -0,0 +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 }
end
end

View File

@@ -0,0 +1,103 @@
require "ostruct"
class ImportsController < ApplicationController
before_action :set_import, except: %i[ index new create ]
def index
@imports = Current.family.imports
render layout: "with_sidebar"
end
def new
account = Current.family.accounts.find_by(id: params[:account_id])
@import = Import.new account: account
end
def edit
end
def update
account = Current.family.accounts.find(params[:import][:account_id])
@import.update! account: account
redirect_to load_import_path(@import), notice: t(".import_updated")
end
def create
account = Current.family.accounts.find(params[:import][:account_id])
@import = Import.create!(account: account)
redirect_to load_import_path(@import), notice: t(".import_created")
end
def destroy
@import.destroy!
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
end
def load
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
render :load, status: :unprocessable_entity
end
end
def configure
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")
end
end
def update_mappings
@import.update! import_params(@import.expected_fields.map(&:key))
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
end
def clean
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")
end
end
def update_csv
update_params = import_params[:csv_update]
@import.update_csv! \
row_idx: update_params[:row_idx],
col_idx: update_params[:col_idx],
value: update_params[:value]
render :clean
end
def confirm
unless @import.cleaned?
redirect_to clean_import_path(@import), alert: t(".invalid_data")
end
end
def publish
if @import.valid?
@import.publish_later
redirect_to imports_path, notice: t(".import_published")
else
flash.now[:error] = t(".invalid_data")
render :confirm, status: :unprocessable_entity
end
end
private
def set_import
@import = Current.family.imports.find(params[:id])
end
def import_params(permitted_mappings = nil)
params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
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,4 +1,6 @@
class PagesController < ApplicationController
layout "with_sidebar"
include Filterable
def dashboard
@@ -6,6 +8,35 @@ class PagesController < ApplicationController
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@liability_series = snapshot[:liability_series]
@account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency)
snapshot_transactions = Current.family.snapshot_transactions
@income_series = snapshot_transactions[:income_series]
@spending_series = snapshot_transactions[:spending_series]
@savings_rate_series = snapshot_transactions[:savings_rate_series]
snapshot_account_transactions = Current.family.snapshot_account_transactions
@top_spenders = snapshot_account_transactions[:top_spenders]
@top_earners = snapshot_account_transactions[:top_earners]
@top_savers = snapshot_account_transactions[:top_savers]
@accounts = Current.family.accounts
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|
{ date: Date.current - i.days, value: Money.new(0) }
end
@investing_series = TimeSeries.new(placeholder_series_data)
end
def changelog
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
end
def feedback
end
def invites
end
end

View File

@@ -3,7 +3,7 @@ class PasswordResetsController < ApplicationController
layout "auth"
before_action :set_user_by_token, only: :update
before_action :set_user_by_token, only: %i[ edit update ]
def new
end
@@ -20,6 +20,7 @@ class PasswordResetsController < ApplicationController
end
def edit
@user = User.new
end
def update

View File

@@ -13,9 +13,10 @@ class RegistrationsController < ApplicationController
def create
family = Family.new
@user.family = family
@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

@@ -0,0 +1,7 @@
class Settings::BillingsController < SettingsController
def edit
end
def update
end
end

View File

@@ -0,0 +1,71 @@
class Settings::HostingsController < SettingsController
before_action :verify_hosting_mode
def show
end
def update
if all_updates_valid?
hosting_params.keys.each do |key|
Setting.send("#{key}=", hosting_params[key].strip)
end
redirect_to settings_hosting_path, notice: t(".success")
else
flash.now[:error] = @errors.first.message
render :show, status: :unprocessable_entity
end
end
def send_test_email
unless Setting.smtp_settings_populated?
flash[:error] = t(".missing_smtp_setting_error")
render(:show, status: :unprocessable_entity)
return
end
begin
NotificationMailer.with(user: Current.user).test_email.deliver_now
rescue => _e
flash[:error] = t(".error")
render :show, status: :unprocessable_entity
return
end
redirect_to settings_hosting_path, notice: t(".success")
end
private
def all_updates_valid?
@errors = ActiveModel::Errors.new(Setting)
hosting_params.keys.each do |key|
setting = Setting.new(var: key)
setting.value = hosting_params[key].strip
unless setting.valid?
@errors.merge!(setting.errors)
end
end
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
@errors.empty?
end
def hosting_params
permitted_params = params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password)
result = {}
result[:upgrades_mode] = permitted_params[:upgrades_mode] == "manual" ? "manual" : "auto" if permitted_params.key?(:upgrades_mode)
result[:render_deploy_hook] = permitted_params[:render_deploy_hook] if permitted_params.key?(:render_deploy_hook)
result[:upgrades_target] = permitted_params[:upgrades_mode] unless permitted_params[:upgrades_mode] == "manual" if permitted_params.key?(:upgrades_mode)
result.merge!(permitted_params.slice(:email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password))
result
end
def verify_hosting_mode
head :not_found unless self_hosted?
end
end

View File

@@ -0,0 +1,7 @@
class Settings::NotificationsController < SettingsController
def edit
end
def update
end
end

View File

@@ -0,0 +1,26 @@
class Settings::PreferencesController < SettingsController
def edit
end
def update
preference_params_with_family = preference_params
if Current.family && preference_params[:family_attributes]
family_attributes = preference_params[:family_attributes].merge({ id: Current.family.id })
preference_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(preference_params_with_family)
redirect_to settings_preferences_path, notice: t(".success")
else
redirect_to settings_preferences_path, notice: t(".success")
render :show, status: :unprocessable_entity
end
end
private
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency ])
end
end

View File

@@ -0,0 +1,39 @@
class Settings::ProfilesController < SettingsController
def show
end
def update
user_params_with_family = user_params
if params[:user][:delete_profile_image] == "true"
Current.user.profile_image.purge
end
if Current.family && user_params_with_family[:family_attributes]
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
user_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(user_params_with_family)
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: t(".file_size_error")
end
end
def destroy
if Current.user.deactivate
logout
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])
end
end

View File

@@ -0,0 +1,7 @@
class Settings::SecuritiesController < SettingsController
def edit
end
def update
end
end

View File

@@ -1,46 +0,0 @@
class Settings::SelfHostingController < ApplicationController
before_action :verify_self_hosting_enabled
def edit
end
def update
if all_updates_valid?
self_hosting_params.keys.each do |key|
Setting.send("#{key}=", self_hosting_params[key].strip)
end
redirect_to edit_settings_self_hosting_path, notice: t(".success")
else
flash.now[:error] = @errors.first.message
render :edit, status: :unprocessable_entity
end
end
private
def all_updates_valid?
@errors = ActiveModel::Errors.new(Setting)
self_hosting_params.keys.each do |key|
setting = Setting.new(var: key)
setting.value = self_hosting_params[key].strip
unless setting.valid?
@errors.merge!(setting.errors)
end
end
if self_hosting_params[:upgrades_mode] == "auto" && self_hosting_params[:render_deploy_hook].blank?
@errors.add(:render_deploy_hook, t("settings.self_hosting.update.render_deploy_hook_error"))
end
@errors.empty?
end
def self_hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :upgrades_target)
end
def verify_self_hosting_enabled
head :not_found unless self_hosted?
end
end

View File

@@ -1,26 +1,3 @@
class SettingsController < ApplicationController
def edit
end
def update
user_params_with_family = user_params
if Current.family
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
user_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(user_params_with_family)
redirect_to root_path, notice: "Profile updated successfully."
else
render :edit, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation,
family_attributes: [ :name, :id, :currency ])
end
layout "with_sidebar"
end

View File

@@ -0,0 +1,24 @@
class Tag::DeletionsController < ApplicationController
layout "with_sidebar"
before_action :set_tag
before_action :set_replacement_tag, only: :create
def new
end
def create
@tag.replace_and_destroy! @replacement_tag
redirect_back_or_to tags_path, notice: t(".deleted")
end
private
def set_tag
@tag = Current.family.tags.find_by(id: params[:tag_id])
end
def set_replacement_tag
@replacement_tag = Current.family.tags.find_by(id: params[:replacement_tag_id])
end
end

View File

@@ -0,0 +1,36 @@
class TagsController < ApplicationController
layout "with_sidebar"
before_action :set_tag, only: %i[ edit update ]
def index
@tags = Current.family.tags.alphabetically
end
def new
@tag = Current.family.tags.new color: Tag::COLORS.sample
end
def create
Current.family.tags.create!(tag_params)
redirect_to tags_path, notice: t(".created")
end
def edit
end
def update
@tag.update!(tag_params)
redirect_to tags_path, notice: t(".updated")
end
private
def set_tag
@tag = Current.family.tags.find(params[:id])
end
def tag_params
params.require(:tag).permit(:name, :color)
end
end

View File

@@ -1,34 +0,0 @@
class Transactions::CategoriesController < ApplicationController
before_action :set_category, only: [ :update, :destroy ]
def create
if Current.family.transaction_categories.create(category_params)
redirect_to transactions_path, notice: t(".success")
else
render transactions_path, status: :unprocessable_entity, notice: t(".error")
end
end
def update
if @category.update(category_params)
redirect_to transactions_path, notice: t(".success")
else
render transactions_path, status: :unprocessable_entity, notice: t(".error")
end
end
def destroy
@category.destroy!
redirect_to transactions_path, notice: t(".success")
end
private
def set_category
@category = Current.family.transaction_categories.find(params[:id])
end
def category_params
params.require(:transaction_category).permit(:name, :name, :color)
end
end

View File

@@ -1,125 +1,104 @@
class TransactionsController < ApplicationController
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, items: 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] || {}
updated_params = delete_search_param(current_params, params[:remove_param], value: params[:remove_param_value])
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
end
def edit
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
if params[:account_id]
e.account = Current.family.accounts.find(params[:account_id])
end
end
end
def create
account = Current.family.accounts.find(params[:transaction][:account_id])
@entry = Current.family
.accounts
.find(params[:account_entry][:account_id])
.entries
.create!(transaction_entry_params.merge(amount: amount))
@transaction = account.transactions.build(transaction_params)
respond_to do |format|
if @transaction.save
@transaction.account.sync_later
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|
if @transaction.update(transaction_params)
@transaction.account.sync_later
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: 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
@transaction.destroy!
@transaction.account.sync_later
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
def amount
if nature.income?
transaction_entry_params[:amount].to_d * -1
else
params.delete(key)
transaction_entry_params[:amount].to_d
end
params
end
def ransack_session_key
:ransack_transactions_q
def nature
params[:account_entry][:nature].to_s.inquiry
end
# Use callbacks to share common setup or constraints between actions.
def set_transaction
@transaction = Transaction.find(params[:id])
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])
end
# Only allow a list of trusted parameters through.
def transaction_params
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id)
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,65 +0,0 @@
class ValuationsController < ApplicationController
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: Current.family.currency))
if @valuation.save
@valuation.account.sync_later
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
@valuation = Valuation.find(params[:id])
end
def update
@valuation = Valuation.find(params[:id])
if @valuation.update(valuation_params)
@valuation.account.sync_later
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
@valuation = Valuation.find(params[:id])
@account = @valuation.account
@valuation.destroy!
@account.sync_later
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
def valuation_params
params.require(:valuation).permit(:date, :value)
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]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View File

@@ -0,0 +1,2 @@
module Account::TransfersHelper
end

View File

@@ -7,6 +7,10 @@ module AccountsHelper
class_mapping(accountable_type)[:text]
end
def accountable_fill_class(accountable_type)
class_mapping(accountable_type)[:fill]
end
def accountable_bg_class(accountable_type)
class_mapping(accountable_type)[:bg]
end
@@ -15,18 +19,22 @@ module AccountsHelper
class_mapping(accountable_type)[:bg_transparent]
end
def accountable_color(accountable_type)
class_mapping(accountable_type)[:hex]
end
private
def class_mapping(accountable_type)
{
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10" },
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10" },
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" },
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10" },
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10" },
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10" },
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10" },
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" })
"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

@@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
# 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)
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
money_amount_method = method.to_s.chomp("_money").to_sym
@@ -33,26 +33,36 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
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,
placeholder: Money.new(0, money&.currency || Money.default_currency).format
"data-money-field-target" => "amount",
placeholder: Money.new(0, currency).format,
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = default_options.merge(options)
grouped_options = currency_options_for_select
selected_currency = money&.currency&.iso_code
selected_currency = money&.currency&.iso_code || currency.iso_code
@template.form_field_tag do
@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")
end
@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" }
@@ -66,6 +76,20 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
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)
@@ -74,7 +98,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
@template.form_field_tag do
label(method, *label_args(options)) +
super(method, choices, options, merged_options.except(:label))
super(method, choices, options, merged_options.except(:label))
end
end
@@ -86,7 +110,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
@template.form_field_tag do
label(method, *label_args(options)) +
super(method, collection, value_method, text_method, options, merged_options.except(:label))
super(method, collection, value_method, text_method, options, merged_options.except(:label))
end
end
@@ -99,27 +123,27 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
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)
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" } ]
{
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
end

View File

@@ -17,54 +17,82 @@ module ApplicationHelper
content = tag.p(text)
content = capture &block if block_given?
render partial: "shared/notification", locals: { type: options[:type], content: content }
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
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 modal(&block)
##
# 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: content }
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
end
def account_groups
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: Period.last_30_days).values_at(:assets, :liabilities)
##
# 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 }
def sidebar_link_to(name, path, options = {})
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
classes = [
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm font-medium text-gray-500",
(is_current ? "bg-white text-gray-900 shadow-xs border-alpha-black-50" : "hover:bg-gray-100 border-transparent")
].compact.join(" ")
link_to path, **options.merge(class: classes), aria: { current: ("page" if current_page?(path)) } do
concat(lucide_icon(options[:icon], class: "w-5 h-5")) if options[:icon]
concat(name)
end
end
def sidebar_link_to(name, path, options = {})
base_class_names = [ "block", "border", "border-transparent", "rounded-xl", "-ml-2", "p-2", "text-sm", "font-medium", "text-gray-500", "flex", "items-center" ]
hover_class_names = [ "hover:bg-white", "hover:border-alpha-black-50", "hover:text-gray-900", "hover:shadow-xs" ]
current_page_class_names = [ "bg-white", "border-alpha-black-50", "text-gray-900", "shadow-xs" ]
def mixed_hex_styles(hex)
color = hex || "#1570EF" # blue-600
link_class_names = if current_page?(path) || (request.path.start_with?(path) && path != "/")
base_class_names.delete("border-transparent")
base_class_names + hover_class_names + current_page_class_names
else
base_class_names + hover_class_names
end
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
color: #{color};
STYLE
end
merged_options = options.reverse_merge(class: link_class_names.join(" ")).except(:icon)
def circle_logo(name, hex: nil, size: "md")
render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
end
link_to path, merged_options do
lucide_icon(options[:icon], class: "w-5 h-5 mr-2") + name
end
def return_to_path(params, fallback = root_path)
uri = URI.parse(params[:return_to] || fallback)
uri.relative? ? uri.path : root_path
end
def trend_styles(trend)
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
return fallback if trend.nil? || trend.direction == "flat"
return fallback if trend.nil? || trend.direction.flat?
bg_class, text_class, symbol, icon = case trend.direction
when "up"
trend.type == "liability" ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
trend.favorable_direction.down? ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
when "down"
trend.type == "liability" ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
trend.favorable_direction.down? ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
when "flat"
[ "bg-gray-500/5", "text-gray-500", "", "minus" ]
else
@@ -108,4 +136,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,5 +1,37 @@
module FormsHelper
def form_field_tag(&)
tag.div class: "form-field", &
def form_field_tag(options = {}, &block)
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
tag.div **options, &block
end
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
form.label name, for: form.field_id(name, value), class: "group has-[:disabled]:cursor-not-allowed" do
concat radio_tab_contents(label:, icon:)
concat form.radio_button(name, value, checked:, disabled:, class: "hidden")
end
end
def selectable_categories
Current.family.categories.alphabetically
end
def selectable_merchants
Current.family.merchants.alphabetically
end
def selectable_accounts
Current.family.accounts.alphabetically
end
def selectable_tags
Current.family.tags.alphabetically.pluck(:name, :id)
end
private
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")
concat tag.span(label, class: "group-has-[:checked]:font-semibold")
end
end
end

View File

@@ -0,0 +1,19 @@
module ImportsHelper
def table_corner_class(row_idx, col_idx, rows, cols)
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
""
end
def nav_steps(import = Import.new)
[
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
]
end
end

View File

@@ -0,0 +1,5 @@
module InstitutionsHelper
def institution_logo(institution)
institution.logo.attached? ? institution.logo : institution.logo_url
end
end

View File

@@ -0,0 +1,38 @@
module MenusHelper
def contextual_menu(&block)
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
concat contextual_menu_icon
concat contextual_menu_content(&block)
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
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
end
end
def contextual_menu_content(&block)
tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
capture(&block)
end
end
end

View File

@@ -0,0 +1,2 @@
module Settings::HostingHelper
end

View File

@@ -1,2 +0,0 @@
module Settings::SelfHostingHelper
end

View File

@@ -0,0 +1,14 @@
module SettingsHelper
def next_setting(title, path)
render partial: "settings/nav_link_large", locals: { path: path, direction: "next", title: title }
end
def previous_setting(title, path)
render partial: "settings/nav_link_large", locals: { path: path, direction: "previous", title: title }
end
def settings_section(title:, subtitle: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content }
end
end

View File

@@ -0,0 +1,7 @@
module TagsHelper
def null_tag
Tag.new \
name: "Uncategorized",
color: Tag::UNCATEGORIZED_COLOR
end
end

View File

@@ -1,2 +0,0 @@
module Transactions::CategoriesHelper
end

View File

@@ -1,2 +1,37 @@
module TransactionsHelper
def transaction_search_filters
[
{ 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 get_transaction_search_filter_partial_path(filter)
"transactions/searches/filters/#{filter[:key]}"
end
def get_default_transaction_search_filter
transaction_search_filters[0]
end
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,12 +1,12 @@
module UpgradesHelper
def upgrade_notification
def get_upgrade_for_notification(user, upgrades_mode)
return nil unless ENV["UPGRADES_ENABLED"] == "true"
completed_upgrade = Upgrader.completed_upgrade
return completed_upgrade if completed_upgrade && Current.user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
available_upgrade = Upgrader.available_upgrade
if available_upgrade && Setting.upgrades_mode == "manual" && Current.user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
available_upgrade
end
end

View File

@@ -1,2 +0,0 @@
module ValuationsHelper
end

View File

@@ -0,0 +1,17 @@
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
end
end

View File

@@ -10,7 +10,7 @@ Turbo.setConfirmMethod((message) => {
const dialog = document.getElementById("turbo-confirm");
try {
const { title, body, accept } = JSON.parse(message);
const { title, body, accept, acceptClass } = JSON.parse(message);
if (title) {
document.getElementById("turbo-confirm-title").innerHTML = title;
@@ -23,6 +23,10 @@ Turbo.setConfirmMethod((message) => {
if (accept) {
document.getElementById("turbo-confirm-accept").innerHTML = accept;
}
if (acceptClass) {
document.getElementById("turbo-confirm-accept").className = acceptClass;
}
} catch (e) {
document.getElementById("turbo-confirm-title").innerText = message;
}

View File

@@ -2,18 +2,25 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// By default, auto-submit is "opt-in" to avoid unexpected behavior. Each `auto` target
// will trigger a form submission when the input event is triggered.
// will trigger a form submission when the configured event is triggered.
static targets = ["auto"];
static values = {
triggerEvent: { type: String, default: "input" },
};
connect() {
this.autoTargets.forEach((element) => {
element.addEventListener("input", this.handleInput);
const event =
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
element.addEventListener(event, this.handleInput);
});
}
disconnect() {
this.autoTargets.forEach((element) => {
element.removeEventListener("input", this.handleInput);
const event =
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
element.removeEventListener(event, this.handleInput);
});
}

View File

@@ -0,0 +1,133 @@
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 = []
}
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,59 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [ "input", "decoration" ]
static values = { selection: String }
connect() {
this.#renderOptions()
}
select({ target }) {
this.selectionValue = target.dataset.value
}
selectionValueChanged() {
this.#options.forEach(option => {
if (option.dataset.value === this.selectionValue) {
this.#check(option)
this.inputTarget.value = this.selectionValue
} else {
this.#uncheck(option)
}
})
}
#renderOptions() {
this.#options.forEach(option => option.style.backgroundColor = option.dataset.value)
}
#check(option) {
option.setAttribute("aria-checked", "true")
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(option.dataset.value, 0.2)}`
this.decorationTarget.style.backgroundColor = option.dataset.value
}
#uncheck(option) {
option.setAttribute("aria-checked", "false")
option.style.boxShadow = "none"
}
get #options() {
return Array.from(this.element.querySelectorAll("[role='radio']"))
}
}
function hexToRGBA(hex, alpha = 1) {
hex = hex.replace(/^#/, '');
if (hex.length === 8) {
alpha = parseInt(hex.slice(6, 8), 16) / 255;
hex = hex.slice(0, 6);
}
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["replacementField", "submitButton"]
static classes = [ "dangerousAction", "safeAction" ]
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String
}
updateSubmitButton() {
if (this.replacementFieldTarget.value) {
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
this.#markSafe()
} else {
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue
this.#markDangerous()
}
}
#markSafe() {
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses)
this.submitButtonTarget.classList.add(...this.safeActionClasses)
}
#markDangerous() {
this.submitButtonTarget.classList.remove(...this.safeActionClasses)
this.submitButtonTarget.classList.add(...this.dangerousActionClasses)
}
}

View File

@@ -1,13 +1,17 @@
import { Controller } from "@hotwired/stimulus"
import { install, uninstall } from "@github/hotkey"
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
// Connects to data-controller="hotkey"
export default class extends Controller {
connect() {
install(this.element)
install(this.element);
}
disconnect() {
uninstall(this.element)
uninstall(this.element);
}
navigateBack(event) {
window.history.back();
}
}

View File

@@ -1,270 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import tailwindColors from "@maybe/tailwindcolors";
import * as d3 from "d3";
// Connects to data-controller="line-chart"
export default class extends Controller {
static values = { series: Object };
connect() {
this.renderChart(this.seriesValue);
document.addEventListener("turbo:load", this.renderChart);
}
disconnect() {
document.removeEventListener("turbo:load", this.renderChart);
}
renderChart = () => {
const data = this.prepareData(this.seriesValue);
this.drawChart(data);
};
trendStyles(trendDirection) {
return {
up: {
icon: "↑",
color: tailwindColors.success,
},
down: {
icon: "↓",
color: tailwindColors.error,
},
flat: {
icon: "→",
color: tailwindColors.gray[500],
},
}[trendDirection];
}
prepareData(series) {
return series.values.map((b) => ({
date: new Date(b.date + "T00:00:00"),
value: +b.value.amount,
styles: this.trendStyles(b.trend.direction),
trend: b.trend,
formatted: {
value: Intl.NumberFormat(undefined, {
style: "currency",
currency: b.value.currency || "USD",
}).format(b.value.amount),
change: Intl.NumberFormat(undefined, {
style: "currency",
currency: b.value.currency || "USD",
signDisplay: "always",
}).format(b.trend.value.amount),
},
}));
}
drawChart(data) {
const chartContainer = d3.select(this.element);
// Clear any existing chart
chartContainer.selectAll("svg").remove();
const initialDimensions = {
width: chartContainer.node().clientWidth,
height: chartContainer.node().clientHeight,
};
const svg = chartContainer
.append("svg")
.attr("width", initialDimensions.width)
.attr("height", initialDimensions.height)
.attr("viewBox", [
0,
0,
initialDimensions.width,
initialDimensions.height,
])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
if (data.length === 1) {
this.renderEmpty(svg, initialDimensions);
return;
}
const margin = { top: 20, right: 1, bottom: 30, left: 1 },
width = +svg.attr("width") - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom,
g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// X-Axis
const x = d3
.scaleTime()
.rangeRound([0, width])
.domain(d3.extent(data, (d) => d.date));
const PADDING = 0.15; // 15% padding on top and bottom of data
const dataMin = d3.min(data, (d) => d.value);
const dataMax = d3.max(data, (d) => d.value);
const padding = (dataMax - dataMin) * PADDING;
// Y-Axis
const y = d3
.scaleLinear()
.rangeRound([height, 0])
.domain([dataMin - padding, dataMax + padding]);
// X-Axis labels
g.append("g")
.attr("transform", `translate(0,${height})`)
.call(
d3
.axisBottom(x)
.tickValues([data[0].date, data[data.length - 1].date])
.tickSize(0)
.tickFormat(d3.timeFormat("%b %Y"))
)
.select(".domain")
.remove();
g.selectAll(".tick text")
.style("fill", tailwindColors.gray[500])
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
.attr("dx", (d, i) => {
// We know we only have 2 values
return i === 0 ? "5em" : "-5em";
})
.attr("dy", "0em");
// Line
const line = d3
.line()
.x((d) => x(d.date))
.y((d) => y(d.value));
g.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", tailwindColors.green[500])
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 1.5)
.attr("class", "line-chart-path")
.attr("d", line);
const tooltip = d3
.select("#lineChart")
.append("div")
.style("position", "absolute")
.style("padding", "8px")
.style("font", "14px Inter, sans-serif")
.style("background", tailwindColors.white)
.style("border", `1px solid ${tailwindColors["alpha-black"][100]}`)
.style("border-radius", "10px")
.style("pointer-events", "none")
.style("opacity", 0); // Starts as hidden
// Helper to find the closest data point to the mouse
const bisectDate = d3.bisector(function (d) {
return d.date;
}).left;
// Create an invisible rectangle that captures mouse events (regular SVG elements don't capture mouse events by default)
g.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "none")
.attr("pointer-events", "all")
// When user hovers over the chart, show the tooltip and a circle at the closest data point
.on("mousemove", (event) => {
tooltip.style("opacity", 1);
const tooltipWidth = 250; // Estimate or dynamically calculate the tooltip width
const pageWidth = document.body.clientWidth;
const tooltipX = event.pageX + 10;
const overflowX = tooltipX + tooltipWidth - pageWidth;
const [xPos] = d3.pointer(event);
const x0 = bisectDate(data, x.invert(xPos), 1);
const d0 = data[x0 - 1];
const d1 = data[x0];
const d = xPos - x(d0.date) > x(d1.date) - xPos ? d1 : d0;
// Adjust tooltip position based on overflow
const adjustedX =
overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
g.selectAll(".data-point-circle").remove(); // Remove existing circles to ensure only one is shown at a time
g.append("circle")
.attr("class", "data-point-circle")
.attr("cx", x(d.date))
.attr("cy", y(d.value))
.attr("r", 8)
.attr("fill", tailwindColors.green[500])
.attr("fill-opacity", "0.1")
.attr("pointer-events", "none");
g.append("circle")
.attr("class", "data-point-circle")
.attr("cx", x(d.date))
.attr("cy", y(d.value))
.attr("r", 3)
.attr("fill", tailwindColors.green[500])
.attr("pointer-events", "none");
tooltip
.html(
`<div style="margin-bottom: 4px; color: ${
tailwindColors.gray[500]
}">${d3.timeFormat("%b %d, %Y")(d.date)}</div>
<div style="display: flex; align-items: center; gap: 8px;">
<svg width="10" height="10">
<circle cx="5" cy="5" r="4" stroke="${
d.styles.color
}" fill="transparent" stroke-width="1"></circle>
</svg>
${d.formatted.value} <span style="color: ${
d.styles.color
};">${d.formatted.change} (${d.trend.percent}%)</span>
</div>`
)
.style("left", adjustedX + "px")
.style("top", event.pageY - 10 + "px");
g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time
g.append("line")
.attr("class", "guideline")
.attr("x1", x(d.date))
.attr("y1", 0)
.attr("x2", x(d.date))
.attr("y2", height)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4");
})
.on("mouseout", () => {
g.selectAll(".guideline").remove();
g.selectAll(".data-point-circle").remove();
tooltip.style("opacity", 0);
});
}
// Dot in middle of chart as placeholder for empty chart
renderEmpty(svg, { width, height }) {
svg
.append("line")
.attr("x1", width / 2)
.attr("y1", 0)
.attr("x2", width / 2)
.attr("y2", height)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4");
svg
.append("circle")
.attr("cx", width / 2)
.attr("cy", height / 2)
.attr("r", 4)
.style("fill", tailwindColors.gray[400]);
svg.selectAll(".tick").remove();
svg.selectAll(".domain").remove();
}
}

View File

@@ -0,0 +1,32 @@
import {Controller} from "@hotwired/stimulus";
// Connects to data-controller="merchant-avatar"
// Used by the transaction merchant form to show a preview of what the avatar will look like
export default class extends Controller {
static targets = [
"name",
"color",
"avatar"
];
connect() {
this.nameTarget.addEventListener("input", this.handleNameChange);
this.colorTarget.addEventListener("input", this.handleColorChange);
}
disconnect() {
this.nameTarget.removeEventListener("input", this.handleNameChange);
this.colorTarget.removeEventListener("input", this.handleColorChange);
}
handleNameChange = (e) => {
this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase();
}
handleColorChange = (e) => {
const color = e.currentTarget.value;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color;
}
}

View File

@@ -0,0 +1,20 @@
import { Controller } from "@hotwired/stimulus";
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"];
handleCurrencyChange() {
const selectedCurrency = event.target.value;
this.updateAmount(selectedCurrency);
}
updateAmount(currency) {
(new CurrenciesService).get(currency).then((data) => {
this.amountTarget.placeholder = data.placeholder;
this.amountTarget.step = data.step;
});
}
}

View File

@@ -0,0 +1,176 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
// Connects to data-controller="pie-chart"
export default class extends Controller {
static values = {
data: Array,
label: String,
};
#d3SvgMemo = null;
#d3GroupMemo = null;
#d3ContentMemo = null;
#d3ViewboxWidth = 200;
#d3ViewboxHeight = 200;
connect() {
this.#draw();
document.addEventListener("turbo:load", this.#redraw);
}
disconnect() {
this.#teardown();
document.removeEventListener("turbo:load", this.#redraw);
}
#redraw = () => {
this.#teardown();
this.#draw();
};
#teardown() {
this.#d3SvgMemo = null;
this.#d3GroupMemo = null;
this.#d3ContentMemo = null;
this.#d3Container.selectAll("*").remove();
}
#draw() {
this.#d3Container.attr("class", "relative");
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
const pie = d3
.pie()
.value((d) => d.percent_of_total)
.padAngle(0.06);
const arc = d3
.arc()
.innerRadius(this.#radius - 8)
.outerRadius(this.#radius)
.cornerRadius(2);
const arcs = this.#d3Group
.selectAll("arc")
.data(pie(this.dataValue))
.enter()
.append("g")
.attr("class", "arc");
const paths = arcs
.append("path")
.attr("class", (d) => d.data.fill_color)
.attr("d", arc);
paths
.on("mouseover", (event) => {
this.#d3Svg.selectAll(".arc path").attr("class", "fill-gray-200");
d3.select(event.target).attr("class", (d) => d.data.fill_color);
this.#d3ContentMemo.html(
this.#contentDetailTemplate(d3.select(event.target).datum().data),
);
})
.on("mouseout", () => {
this.#d3Svg
.selectAll(".arc path")
.attr("class", (d) => d.data.fill_color);
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
});
}
#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>`;
}
#contentDetailTemplate(datum) {
return `
<span>${this.#currencyValue(datum)}</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>
<span>${datum.percent_of_total}%</span>
</div>
`;
}
#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;
}
get #d3Container() {
return d3.select(this.element);
}
get #d3Svg() {
if (this.#d3SvgMemo) {
return this.#d3SvgMemo;
} else {
return (this.#d3SvgMemo = this.#createMainSvg());
}
}
get #d3Group() {
if (this.#d3GroupMemo) {
return this.#d3GroupMemo;
} else {
return (this.#d3GroupMemo = this.#createMainGroup());
}
}
get #d3Content() {
if (this.#d3ContentMemo) {
return this.#d3ContentMemo;
} else {
return (this.#d3ContentMemo = this.#createContent());
}
}
#createMainSvg() {
return this.#d3Container
.append("svg")
.attr("width", "100%")
.attr("class", "relative aspect-1")
.attr("viewBox", [0, 0, this.#d3ViewboxWidth, this.#d3ViewboxHeight]);
}
#createMainGroup() {
return this.#d3Svg
.append("g")
.attr(
"transform",
`translate(${this.#d3ViewboxWidth / 2},${this.#d3ViewboxHeight / 2})`,
);
}
#createContent() {
this.#d3ContentMemo = this.#d3Container
.append("div")
.attr(
"class",
"absolute inset-0 w-full text-center flex flex-col items-center justify-center",
);
return this.#d3ContentMemo;
}
}

View File

@@ -0,0 +1,27 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
preview(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
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");
};
reader.readAsDataURL(file);
}
}
clear() {
this.deleteFieldTarget.value = true;
this.fileFieldTarget.value = null;
this.templateTarget.classList.remove("hidden");
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
this.clearBtnTarget.classList.add("hidden");
this.element.submit();
}
}

View File

@@ -16,7 +16,10 @@ export default class extends Controller {
}
select(event) {
this.updateClasses(event.target.dataset.id);
const element = event.target.closest("[data-id]");
if (element) {
this.updateClasses(element.dataset.id);
}
}
onTurboLoad = () => {

View File

@@ -0,0 +1,520 @@
import { Controller } from "@hotwired/stimulus"
import tailwindColors from "@maybe/tailwindcolors"
import * as d3 from "d3"
export default class extends Controller {
static values = {
data: Object,
strokeWidth: { type: Number, default: 2 },
useLabels: { type: Boolean, default: true },
useTooltip: { type: Boolean, default: true },
usePercentSign: Boolean
}
#d3SvgMemo = null
#d3GroupMemo = null
#d3Tooltip = null
#d3InitialContainerWidth = 0
#d3InitialContainerHeight = 0
#normalDataPoints = []
connect() {
this.#install()
document.addEventListener("turbo:load", this.#reinstall)
}
disconnect() {
this.#teardown()
document.removeEventListener("turbo:load", this.#reinstall)
}
#reinstall = () => {
this.#teardown()
this.#install()
}
#teardown() {
this.#d3SvgMemo = null
this.#d3GroupMemo = null
this.#d3Tooltip = null
this.#normalDataPoints = []
this.#d3Container.selectAll("*").remove()
}
#install() {
this.#normalizeDataPoints()
this.#rememberInitialContainerSize()
this.#draw()
}
#normalizeDataPoints() {
this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({
...d,
date: new Date(d.date),
value: d.value.amount ? +d.value.amount : +d.value,
currency: d.value.currency
}))
}
#rememberInitialContainerSize() {
this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
}
#draw() {
if (this.#normalDataPoints.length < 2) {
this.#drawEmpty()
} else {
this.#drawChart()
}
}
#drawEmpty() {
this.#d3Svg.selectAll(".tick").remove()
this.#d3Svg.selectAll(".domain").remove()
this.#drawDashedLineEmptyState()
this.#drawCenteredCircleEmptyState()
}
#drawDashedLineEmptyState() {
this.#d3Svg
.append("line")
.attr("x1", this.#d3InitialContainerWidth / 2)
.attr("y1", 0)
.attr("x2", this.#d3InitialContainerWidth / 2)
.attr("y2", this.#d3InitialContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
}
#drawCenteredCircleEmptyState() {
this.#d3Svg
.append("circle")
.attr("cx", this.#d3InitialContainerWidth / 2)
.attr("cy", this.#d3InitialContainerHeight / 2)
.attr("r", 4)
.style("fill", tailwindColors.gray[400])
}
#drawChart() {
this.#drawTrendline()
if (this.useLabelsValue) {
this.#drawXAxisLabels()
this.#drawGradientBelowTrendline()
}
if (this.useTooltipValue) {
this.#drawTooltip()
this.#trackMouseForShowingTooltip()
}
}
#drawTrendline() {
this.#installTrendlineSplit()
this.#d3Group
.append("path")
.datum(this.#normalDataPoints)
.attr("fill", "none")
.attr("stroke", `url(#${this.element.id}-split-gradient)`)
.attr("d", this.#d3Line)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", this.strokeWidthValue)
}
#installTrendlineSplit() {
const gradient = this.#d3Svg
.append("defs")
.append("linearGradient")
.attr("id", `${this.element.id}-split-gradient`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", this.#d3XScale.range()[0])
.attr("x2", this.#d3XScale.range()[1])
gradient.append("stop")
.attr("class", "start-color")
.attr("offset", "0%")
.attr("stop-color", this.#trendColor)
gradient.append("stop")
.attr("class", "middle-color")
.attr("offset", "100%")
.attr("stop-color", this.#trendColor)
gradient.append("stop")
.attr("class", "end-color")
.attr("offset", "100%")
.attr("stop-color", tailwindColors.gray[300])
}
#setTrendlineSplitAt(percent) {
this.#d3Svg
.select(`#${this.element.id}-split-gradient`)
.select(".middle-color")
.attr("offset", `${percent * 100}%`)
this.#d3Svg
.select(`#${this.element.id}-split-gradient`)
.select(".end-color")
.attr("offset", `${percent * 100}%`)
this.#d3Svg
.select(`#${this.element.id}-trendline-gradient-rect`)
.attr("width", this.#d3ContainerWidth * percent)
}
#drawXAxisLabels() {
// Add ticks
this.#d3Group
.append("g")
.attr("transform", `translate(0,${this.#d3ContainerHeight})`)
.call(
d3
.axisBottom(this.#d3XScale)
.tickValues([ this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date ])
.tickSize(0)
.tickFormat(d3.timeFormat("%d %b %Y"))
)
.select(".domain")
.remove()
// Style ticks
this.#d3Group.selectAll(".tick text")
.style("fill", tailwindColors.gray[500])
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
.attr("dx", (_d, i) => {
// We know we only have 2 values
return i === 0 ? "5em" : "-5em"
})
.attr("dy", "0em")
}
#drawGradientBelowTrendline() {
// Define gradient
const gradient = this.#d3Group
.append("defs")
.append("linearGradient")
.attr("id", `${this.element.id}-trendline-gradient`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value)))
.attr("y2", this.#d3ContainerHeight)
gradient
.append("stop")
.attr("offset", 0)
.attr("stop-color", this.#trendColor)
.attr("stop-opacity", 0.06)
gradient
.append("stop")
.attr("offset", 0.5)
.attr("stop-color", this.#trendColor)
.attr("stop-opacity", 0)
// Clip path makes gradient start at the trendline
this.#d3Group
.append("clipPath")
.attr("id", `${this.element.id}-clip-below-trendline`)
.append("path")
.datum(this.#normalDataPoints)
.attr("d", d3.area()
.x(d => this.#d3XScale(d.date))
.y0(this.#d3ContainerHeight)
.y1(d => this.#d3YScale(d.value))
)
// Apply the gradient + clip path
this.#d3Group
.append("rect")
.attr("id", `${this.element.id}-trendline-gradient-rect`)
.attr("width", this.#d3ContainerWidth)
.attr("height", this.#d3ContainerHeight)
.attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`)
.style("fill", `url(#${this.element.id}-trendline-gradient)`)
}
#drawTooltip() {
this.#d3Tooltip = d3
.select(`#${this.element.id}`)
.append("div")
.style("position", "absolute")
.style("padding", "8px")
.style("font", "14px Inter, sans-serif")
.style("background", tailwindColors.white)
.style("border", `1px solid ${tailwindColors["alpha-black"][100]}`)
.style("border-radius", "10px")
.style("pointer-events", "none")
.style("opacity", 0) // Starts as hidden
}
#trackMouseForShowingTooltip() {
const bisectDate = d3.bisector(d => d.date).left
this.#d3Group
.append("rect")
.attr("width", this.#d3ContainerWidth)
.attr("height", this.#d3ContainerHeight)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mousemove", (event) => {
const estimatedTooltipWidth = 250
const pageWidth = document.body.clientWidth
const tooltipX = event.pageX + 10
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth
const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX
const [xPos] = d3.pointer(event)
const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1)
const d0 = this.#normalDataPoints[x0 - 1]
const d1 = this.#normalDataPoints[x0]
const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0
const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth
this.#setTrendlineSplitAt(xPercent)
// Reset
this.#d3Group.selectAll(".data-point-circle").remove()
this.#d3Group.selectAll(".guideline").remove()
// Guideline
this.#d3Group
.append("line")
.attr("class", "guideline")
.attr("x1", this.#d3XScale(d.date))
.attr("y1", 0)
.attr("x2", this.#d3XScale(d.date))
.attr("y2", this.#d3ContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
// Big circle
this.#d3Group
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this.#d3XScale(d.date))
.attr("cy", this.#d3YScale(d.value))
.attr("r", 8)
.attr("fill", this.#trendColor)
.attr("fill-opacity", "0.1")
.attr("pointer-events", "none")
// Small circle
this.#d3Group
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this.#d3XScale(d.date))
.attr("cy", this.#d3YScale(d.value))
.attr("r", 3)
.attr("fill", this.#trendColor)
.attr("pointer-events", "none")
// Render tooltip
this.#d3Tooltip
.html(this.#tooltipTemplate(d))
.style("opacity", 1)
.style("z-index", 999)
.style("left", adjustedX + "px")
.style("top", event.pageY - 10 + "px")
})
.on("mouseout", (event) => {
const hoveringOnGuideline = event.toElement?.classList.contains("guideline")
if (!hoveringOnGuideline) {
this.#d3Group.selectAll(".guideline").remove()
this.#d3Group.selectAll(".data-point-circle").remove()
this.#d3Tooltip.style("opacity", 0)
this.#setTrendlineSplitAt(1)
}
})
}
#tooltipTemplate(datum) {
return(`
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
${d3.timeFormat("%b %d, %Y")(datum.date)}
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<svg width="10" height="10">
<circle
cx="5"
cy="5"
r="4"
stroke="${this.#tooltipTrendColor(datum)}"
fill="transparent"
stroke-width="1"></circle>
</svg>
${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
</div>
${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? `
<span style="width: 80px;"></span>
` : `
<span style="color: ${this.#tooltipTrendColor(datum)};">
${this.#tooltipChange(datum)} (${datum.trend.percent}%)
</span>
`}
</div>
`)
}
#tooltipTrendColor(datum) {
return {
up: tailwindColors.success,
down: tailwindColors.error,
flat: tailwindColors.gray[500],
}[datum.trend.direction]
}
#tooltipValue(datum) {
if (datum.currency) {
return this.#currencyValue(datum)
} else {
return datum.value
}
}
#tooltipChange(datum) {
if (datum.currency) {
return this.#currencyChange(datum)
} else {
return this.#decimalChange(datum)
}
}
#currencyValue(datum) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
}).format(datum.value)
}
#currencyChange(datum) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
signDisplay: "always",
}).format(datum.trend.value.amount)
}
#decimalChange(datum) {
return Intl.NumberFormat(undefined, {
style: "decimal",
signDisplay: "always",
}).format(datum.trend.value)
}
#createMainSvg() {
return this.#d3Container
.append("svg")
.attr("width", this.#d3InitialContainerWidth)
.attr("height", this.#d3InitialContainerHeight)
.attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ])
}
#createMainGroup() {
return this.#d3Svg
.append("g")
.attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
}
get #d3Svg() {
if (this.#d3SvgMemo) {
return this.#d3SvgMemo
} else {
return this.#d3SvgMemo = this.#createMainSvg()
}
}
get #d3Group() {
if (this.#d3GroupMemo) {
return this.#d3GroupMemo
} else {
return this.#d3GroupMemo = this.#createMainGroup()
}
}
get #margin() {
if (this.useLabelsValue) {
return { top: 20, right: 0, bottom: 30, left: 0 }
} else {
return { top: 0, right: 0, bottom: 0, left: 0 }
}
}
get #d3ContainerWidth() {
return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right
}
get #d3ContainerHeight() {
return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom
}
get #d3Container() {
return d3.select(this.element)
}
get #trendColor() {
if (this.#trendDirection === "flat") {
return tailwindColors.gray[500]
} else if (this.#trendDirection === this.#favorableDirection) {
return tailwindColors.green[500]
} else {
return tailwindColors.error
}
}
get #trendDirection() {
return this.dataValue.trend.direction
}
get #favorableDirection() {
return this.dataValue.trend.favorable_direction
}
get #d3Line() {
return d3
.line()
.x(d => this.#d3XScale(d.date))
.y(d => this.#d3YScale(d.value))
}
get #d3XScale() {
return d3
.scaleTime()
.rangeRound([ 0, this.#d3ContainerWidth ])
.domain(d3.extent(this.#normalDataPoints, d => d.date))
}
get #d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.15 : 0.05
const dataMin = d3.min(this.#normalDataPoints, d => d.value)
const dataMax = d3.max(this.#normalDataPoints, d => d.value)
const padding = (dataMax - dataMin) * reductionPercent
return d3
.scaleLinear()
.rangeRound([ this.#d3ContainerHeight, 0 ])
.domain([ dataMin - padding, dataMax + padding ])
}
}

View File

@@ -1,97 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import tailwindColors from "@maybe/tailwindcolors";
import * as d3 from "d3";
export default class extends Controller {
static values = { series: Object };
connect() {
this.renderChart(this.seriesValue);
document.addEventListener("turbo:load", this.renderChart);
}
disconnect() {
document.removeEventListener("turbo:load", this.renderChart);
}
renderChart = () => {
const data = this.prepareData(this.seriesValue);
this.drawChart(data);
};
prepareData(series) {
return series.values.map((d) => ({
date: new Date(d.date + "T00:00:00"),
value: +d.value.amount,
}));
}
drawChart(data) {
const chartContainer = d3.select(this.element);
chartContainer.selectAll("*").remove();
const initialDimensions = {
width: chartContainer.node().clientWidth,
height: chartContainer.node().clientHeight,
};
const svg = chartContainer
.append("svg")
.attr("width", initialDimensions.width)
.attr("height", initialDimensions.height)
.attr("viewBox", [
0,
0,
initialDimensions.width,
initialDimensions.height,
])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
const margin = { top: 0, right: 0, bottom: 0, left: 0 };
const width = initialDimensions.width - margin.left - margin.right;
const height = initialDimensions.height - margin.top - margin.bottom;
const isLiability = this.classificationValue === "liability";
const trendDirection = data[data.length - 1].value - data[0].value;
let lineColor;
if (trendDirection > 0) {
lineColor = isLiability
? tailwindColors.error
: tailwindColors.green[500];
} else if (trendDirection < 0) {
lineColor = isLiability
? tailwindColors.green[500]
: tailwindColors.error;
} else {
lineColor = tailwindColors.gray[500];
}
const xScale = d3
.scaleTime()
.rangeRound([0, width])
.domain(d3.extent(data, (d) => d.date));
const PADDING = 0.05;
const dataMin = d3.min(data, (d) => d.value);
const dataMax = d3.max(data, (d) => d.value);
const padding = (dataMax - dataMin) * PADDING;
const yScale = d3
.scaleLinear()
.rangeRound([height, 0])
.domain([dataMin - padding, dataMax + padding]);
const line = d3
.line()
.x((d) => xScale(d.date))
.y((d) => yScale(d.value));
svg
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", lineColor)
.attr("stroke-width", 2)
.attr("d", line);
}
}

View File

@@ -0,0 +1,5 @@
export class CurrenciesService {
get(id) {
return fetch(`/currencies/${id}.json`).then((response) => response.json());
}
}

View File

@@ -38,17 +38,17 @@ export default {
900: "rgba(255, 255, 255, 0.7)",
},
"alpha-black": {
25: "rgba(20, 20, 20, 0.03)",
50: "rgba(20, 20, 20, 0.05)",
100: "rgba(20, 20, 20, 0.08)",
200: "rgba(20, 20, 20, 0.1)",
300: "rgba(20, 20, 20, 0.15)",
400: "rgba(20, 20, 20, 0.2)",
500: "rgba(20, 20, 20, 0.3)",
600: "rgba(20, 20, 20, 0.4)",
700: "rgba(20, 20, 20, 0.5)",
800: "rgba(20, 20, 20, 0.6)",
900: "rgba(20, 20, 20, 0.7)",
25: "rgba(11, 11, 11, 0.03)",
50: "rgba(11, 11, 11, 0.05)",
100: "rgba(11, 11, 11, 0.08)",
200: "rgba(11, 11, 11, 0.1)",
300: "rgba(11, 11, 11, 0.15)",
400: "rgba(11, 11, 11, 0.2)",
500: "rgba(11, 11, 11, 0.3)",
600: "rgba(11, 11, 11, 0.4)",
700: "rgba(11, 11, 11, 0.5)",
800: "rgba(11, 11, 11, 0.6)",
900: "rgba(11, 11, 11, 0.7)",
},
red: {
25: "#FFFBFB",

View File

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

7
app/jobs/import_job.rb Normal file
View File

@@ -0,0 +1,7 @@
class ImportJob < ApplicationJob
queue_as :default
def perform(import)
import.publish
end
end

View File

@@ -0,0 +1,7 @@
class UserPurgeJob < ApplicationJob
queue_as :default
def perform(user)
user.purge
end
end

View File

@@ -1,4 +1,16 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
after_action :set_self_host_settings, if: -> { Rails.configuration.app_mode.self_hosted? }
private
def set_self_host_settings
mail.from = Setting.email_sender
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
port: Setting.smtp_port,
user_name: Setting.smtp_username,
password: Setting.smtp_password,
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
end
end

View File

@@ -0,0 +1,5 @@
class NotificationMailer < ApplicationMailer
def test_email
mail(to: params[:user].email, subject: t(".test_email_subject"), body: t(".test_email_body"))
end
end

View File

@@ -2,87 +2,109 @@ class Account < ApplicationRecord
include Syncable
include Monetizable
broadcasts_refreshes
validates :family, 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 :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]
class << self
def 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|
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
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 balance_on(date)
balances.where("date <= ?", date).order(date: :desc).first&.balance
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
end
# 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
def favorable_direction
classification == "asset" ? "up" : "down"
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
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
end
rescue Money::ConversionError
TimeSeries.new([])
end
def self.by_group(period: Period.all, currency: Money.default_currency)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current)
Accountable.by_classification.each do |classification, types|
types.each do |type|
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
Accountable.from_type(type).includes(:account).each do |accountable|
account = accountable.account
next unless account
value_node = group.add_value_node(
account,
account.balance_money.exchange_to(currency) || Money.new(0, currency),
account.series(period: period, currency: currency)
)
end
end
if valuation
valuation.update! amount: balance
else
entries.create! \
date: Date.current,
amount: balance,
currency: currency,
entryable: Account::Valuation.new
end
grouped_accounts
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: 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,
{ date: Date.current, balance: @account.balance, currency: @account.currency, updated_at: Time.current } # Last balance must always match "source of truth"
]
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
rate = ExchangeRate.find_by(base_currency: currency, converted_currency: @account.currency, date: date)
raise "Rate for #{currency} to #{@account.currency} not found" unless rate
value *= rate.rate
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
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 == 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,131 @@
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)
balances_to_upsert = balances.map do |balance|
{
date: balance.date,
balance: balance.balance,
currency: balance.currency,
updated_at: Time.now
}
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
transactions = entries.select { |e| e.date == date && e.account_transaction? }
prior_balance - net_transaction_flows(transactions)
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 = entries.select { |e| e.account_transaction? && e.date > sync_start_date }
account.balance + net_transaction_flows(transactions)
end
def find_prior_balance
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
end
def net_transaction_flows(transactions, target_currency = account.currency)
converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_transaction_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

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

@@ -0,0 +1,194 @@
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? }
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
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 ?", "%#{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
end

View File

@@ -0,0 +1,13 @@
module Account::Entryable
extend ActiveSupport::Concern
TYPES = %w[ Account::Valuation Account::Transaction ]
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

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

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