Compare commits

...

89 Commits

Author SHA1 Message Date
Zach Gollwitzer
9ebcb6fc41 Bump to v0.1.0-alpha.18
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-04 15:09:58 -04:00
Zach Gollwitzer
24d3c0243f Handle missing weekend stock prices in sync process (#1242)
* Don't append missing prices if already known

* Add failing test

* Handle weekend stock prices

* Fix tests and gapfill logic
2024-10-04 14:19:45 -04:00
Zach Gollwitzer
e8d7ee3270 Add tag filtering (#1240) 2024-10-04 09:17:48 -04:00
Zach Gollwitzer
73d61fc990 Fix signage on transaction imports (#1236) 2024-10-03 14:59:24 -04:00
Zach Gollwitzer
1ffa13f3b3 Use DB for auth sessions (#1233)
* DB sessions

* Validations for profile image
2024-10-03 14:42:22 -04:00
Zach Gollwitzer
82c298307d Add formatting for EUR locales (#1231)
* Add formatting for EUR locales

* Fix formatting assertion for EUR in english locales
2024-10-03 10:25:38 -04:00
Zach Gollwitzer
ab40289eb4 Allow users to set preferred locale in settings and provide basic date and time localization support (#1226)
* Add basic date and time localization

* Normalize translations

* Localize transaction dates

* Removed unsupported Rails locales
2024-10-02 14:02:17 -04:00
Zach Gollwitzer
7fabca4679 Simplify self host settings controller (#1230) 2024-10-02 12:07:56 -04:00
Zach Gollwitzer
cb75c537fe Fix import migration (#1227) 2024-10-01 18:59:35 -04:00
Zach Gollwitzer
b1d2dc5e97 Add DB connection troubleshooting to self hosting guide
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-01 18:57:38 -04:00
Zach Gollwitzer
c3c0ab3530 Fix incorrect partial sync balance generation (#1223) 2024-10-01 13:15:24 -04:00
Alter Lagos
fa3b1e016c Sort currencies by name as a second order (#1216) 2024-10-01 10:48:39 -04:00
Zach Gollwitzer
398b246965 CSV Imports Overhaul (Transactions, Trades, Accounts, and Mint import support) (#1209)
* Remove stale 1.0 import logic and model

* Fresh start

* Checkpoint before removing nav

* First working prototype

* Add trade, account, and mint import flows

* Basic working version with tests

* System tests for each import type

* Clean up mappings flow

* Clean up PR, refactor stale code, tests

* Add back row validations

* Row validations

* Fix import job test

* Fix import navigation

* Fix mint import configuration form

* Currency preset for new accounts
2024-10-01 10:47:59 -04:00
Jestin Palamuttam
23786b444a Fix: Escape button not being handled on settings pages (#1210)
* fix: escape button handler

* feat: location logic for settings page

* fix: linting errors

* fix: linting error

* refactor: settings test
2024-09-30 17:28:15 -04:00
dependabot[bot]
edbf4eb3d6 Bump aws-sdk-s3 from 1.164.0 to 1.166.0 (#1217)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.164.0 to 1.166.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-09-30 10:19:12 -04:00
dependabot[bot]
367073f046 Bump propshaft from 1.0.0 to 1.0.1 (#1219)
Bumps [propshaft](https://github.com/rails/propshaft) from 1.0.0 to 1.0.1.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v1.0.0...v1.0.1)

---
updated-dependencies:
- dependency-name: propshaft
  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-09-30 10:19:03 -04:00
dependabot[bot]
2cb3d806d8 Bump sentry-rails from 5.19.0 to 5.20.1 (#1220)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.19.0 to 5.20.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.19.0...5.20.1)

---
updated-dependencies:
- dependency-name: sentry-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-09-30 10:18:47 -04:00
dependabot[bot]
3dd0aa2f37 Bump tailwindcss-rails from 2.7.4 to 2.7.6 (#1207)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.4 to 2.7.6.
- [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.7.4...v2.7.6)

---
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-09-24 17:08:29 -04:00
dependabot[bot]
2b9a7fdef3 Bump ruby-lsp-rails from 0.3.14 to 0.3.16 (#1200)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.14 to 0.3.16.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.14...v0.3.16)

---
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-09-24 17:02:09 -04:00
dependabot[bot]
cb14ef7655 Bump aws-sdk-s3 from 1.162.0 to 1.164.0 (#1198)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.162.0 to 1.164.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-09-24 17:01:54 -04:00
dependabot[bot]
73ceebccc2 Bump selenium-webdriver from 4.24.0 to 4.25.0 (#1203)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.24.0 to 4.25.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.24.0...selenium-4.25.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-09-24 17:01:40 -04:00
dependabot[bot]
17f29de773 Bump dotenv-rails from 3.1.2 to 3.1.4 (#1202)
Bumps [dotenv-rails](https://github.com/bkeepers/dotenv) from 3.1.2 to 3.1.4.
- [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.2...v3.1.4)

---
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-09-24 17:01:23 -04:00
dependabot[bot]
60fadc1d68 Bump puma from 6.4.2 to 6.4.3 (#1201)
Bumps [puma](https://github.com/puma/puma) from 6.4.2 to 6.4.3.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/master/History.md)
- [Commits](https://github.com/puma/puma/compare/v6.4.2...v6.4.3)

---
updated-dependencies:
- dependency-name: puma
  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-09-24 17:01:11 -04:00
dependabot[bot]
5eaf335c49 Bump faraday from 2.11.0 to 2.12.0 (#1197)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.11.0 to 2.12.0.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.11.0...v2.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:01:01 -04:00
dependabot[bot]
be8f74b093 Bump turbo-rails from 2.0.7 to 2.0.10 (#1196)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.7 to 2.0.10.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.7...v2.0.10)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:00:47 -04:00
Zach Gollwitzer
b4b4e5df31 Finalize profile settings page for v0.2.0-alpha (#1194)
* Finalize profile settings page

* Translations

* Add ghost button to search menu
2024-09-20 15:56:21 -04:00
Zach Gollwitzer
5942ce7e3c Finalize transaction drawer, simplify money form helpers (#1191)
* Finalize transaction drawer, simplify money form helpers

* Fix money form errors

* Reusable disclosure helper, fix styles

* Final style tweaks
2024-09-20 08:38:19 -04:00
Zach Gollwitzer
730e58d763 Finish remaining transaction filters (#1189)
* Add type filters to transaction search

* Add amount filter
2024-09-17 10:38:02 -04:00
Tony Vincent
e06f0c76f9 Add error handling for vehicle and property account creation (#1179)
* add error handling for vehicle and property account creation

* Add required true for money_field

* Remove rescue in controllers
2024-09-17 10:37:09 -04:00
Zach Gollwitzer
aa6d755402 Fix styles on import modal (#1188)
* Fix styles on import modal

* Remove stale translations
2024-09-16 19:18:07 -04:00
dependabot[bot]
fd40111264 Bump propshaft from 0.9.1 to 1.0.0 (#1187)
Bumps [propshaft](https://github.com/rails/propshaft) from 0.9.1 to 1.0.0.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v0.9.1...v1.0.0)

---
updated-dependencies:
- dependency-name: propshaft
  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-09-16 11:17:16 -04:00
dependabot[bot]
fc0bc1ac96 Bump ruby-lsp-rails from 0.3.13 to 0.3.14 (#1181)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.13 to 0.3.14.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.13...v0.3.14)

---
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-09-16 10:24:05 -04:00
dependabot[bot]
b7e3c61d09 Bump good_job from 4.2.1 to 4.3.0 (#1183)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.2.1 to 4.3.0.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.2.1...v4.3.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-09-16 10:23:54 -04:00
dependabot[bot]
8181781570 Bump tailwindcss-rails from 2.7.3 to 2.7.4 (#1182)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.3 to 2.7.4.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.3...v2.7.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 10:14:06 -04:00
dependabot[bot]
5a5e27685a Bump turbo-rails from 2.0.6 to 2.0.7 (#1185)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 10:13:49 -04:00
dependabot[bot]
cc1954b33b Bump aws-sdk-s3 from 1.160.0 to 1.162.0 (#1184)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.160.0 to 1.162.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-09-16 09:44:12 -04:00
dependabot[bot]
9bb9a062ac Bump pagy from 9.0.8 to 9.0.9 (#1186)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.8 to 9.0.9.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.8...9.0.9)

---
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-09-16 09:43:59 -04:00
Zach Gollwitzer
52d3528361 Bump to v0.1.0-alpha.17
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-09-13 17:24:46 -04:00
Zach Gollwitzer
d3d9af8bce Add basic self hosted onboarding (#1177)
* Add basic self hosted onboarding

* Lint fix

* Normalize translations
2024-09-13 17:24:19 -04:00
Zach Gollwitzer
0149ca4ea1 Transaction page design fixes (#1176)
* Fix transaction summary spacing

* Fix search input padding

* Search and transfer design fixes
2024-09-13 15:43:16 -04:00
Zach Gollwitzer
30f7c120e1 Allow partial investment quantities (#1174) 2024-09-13 11:45:27 -04:00
Zach Gollwitzer
949d3d80fa Support multi-currency transfers (#1175) 2024-09-13 11:45:19 -04:00
Zach Gollwitzer
c28dd8f940 Omit trend if zero in sidebar (#1173)
* Omit trend if zero in sidebar

* Lint fix
2024-09-13 11:28:47 -04:00
Tony Vincent
277e4476d9 Fix missing sync_all_button partial (#1172)
* Fix missing sync_all_button partial

* Add missing translation

* Bring back partial

* Unify button text translation

* Add test
2024-09-13 11:19:20 -04:00
Zach Gollwitzer
b9341ac302 Add sync status and errors to account settings page (#1169) 2024-09-11 17:24:01 -04:00
Valentin Zwerschke
86741401c3 Fix text (#1168)
Signed-off-by: Valentin Zwerschke <vallezw@gmail.com>
2024-09-11 13:40:29 -04:00
Tony Vincent
edf44bec03 Add setting to disable new user registration on self-hosted instances (#1163)
* Add clipboard stimulus controller

* Add invite codes controller

* Setting to force invite code for new signups

* Fix erb linter

* Normalize keys

* Add POST /invite_codes

* Cleanup clipboard_controller.js

* Create invite codes on-demand

* Design changes

* Style alignment

* Update app/views/invite_codes/_invite_code.html.erb

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

* Update app/views/invite_codes/_invite_code.html.erb

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

* Split into individual forms

* Fix missing styles

* Update app/javascript/controllers/clipboard_controller.js

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

* Fix test

---------

Signed-off-by: Tony Vincent <tonyvince7@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-09-11 13:04:39 -04:00
Jestin Palamuttam
5178928b68 fix: html dialog not closing (#1167) 2024-09-11 09:24:38 -04:00
Zach Gollwitzer
ac0ff35360 Update empty account states on dashboard (#1166)
* Update empty account states on dashboard

* Translations
2024-09-10 17:17:10 -04:00
Zach Gollwitzer
04037b8943 Consolidate transaction menu items (#1164)
* Fix valuation frame issue

* Consolidate transactions menu items

* Translations
2024-09-10 14:45:08 -04:00
Zach Gollwitzer
cb13fd2245 Fix valuation frame issue (#1162) 2024-09-09 17:13:52 -04:00
Zach Gollwitzer
eebc07d75e Feedback page (#1160)
* Add feedback page

* Only show latest release on changelog

* Constrain changelog height

* Ignore sanitization warning for Github content

* Add cassette for Github release notes

* Lint fix
2024-09-09 16:54:56 -04:00
dependabot[bot]
c30c1b9698 Bump hotwire-livereload from 1.4.0 to 1.4.1 (#1157)
Bumps [hotwire-livereload](https://github.com/kirillplatonov/hotwire-livereload) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/kirillplatonov/hotwire-livereload/releases)
- [Commits](https://github.com/kirillplatonov/hotwire-livereload/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: hotwire-livereload
  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-09-09 07:57:30 -04:00
dependabot[bot]
d3971f9cee Bump inline_svg from 1.9.0 to 1.10.0 (#1156)
Bumps [inline_svg](https://github.com/jamesmartin/inline_svg) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/jamesmartin/inline_svg/releases)
- [Changelog](https://github.com/jamesmartin/inline_svg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jamesmartin/inline_svg/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: inline_svg
  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-09-09 07:57:15 -04:00
dependabot[bot]
9e4b931612 Bump pg from 1.5.7 to 1.5.8 (#1158)
Bumps [pg](https://github.com/ged/ruby-pg) from 1.5.7 to 1.5.8.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/History.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.5.7...v1.5.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 07:56:05 -04:00
dependabot[bot]
b44da70836 Bump aws-sdk-s3 from 1.159.0 to 1.160.0 (#1159)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.159.0 to 1.160.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-09-09 07:55:51 -04:00
Zach Gollwitzer
0db75a019b Revert "Do not show registation link when REQUIRE_INVITE_CODE=true (#1148)" (#1155)
This reverts commit 9172eb931b.
2024-09-06 11:37:25 -04:00
dependabot[bot]
ee572d8d1f Bump selenium-webdriver from 4.23.0 to 4.24.0 (#1146)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.23.0 to 4.24.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.23.0...selenium-4.24.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-09-06 09:25:22 -04:00
dependabot[bot]
33d007a07b Bump good_job from 4.2.0 to 4.2.1 (#1144)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.2.0...v4.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-06 09:25:11 -04:00
dependabot[bot]
3673ab8f03 Bump faraday from 2.10.1 to 2.11.0 (#1145)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.10.1 to 2.11.0.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.10.1...v2.11.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-06 09:25:02 -04:00
dependabot[bot]
fb42c2ad43 Bump pagy from 9.0.6 to 9.0.8 (#1147)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.6 to 9.0.8.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.6...9.0.8)

---
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-09-06 09:24:44 -04:00
Tony Vincent
9172eb931b Do not show registation link when REQUIRE_INVITE_CODE=true (#1148) 2024-09-06 08:52:26 -04:00
Josh Pigford
0c8cf7e217 Update .gitignore 2024-09-03 12:22:54 -04:00
Josh Pigford
0bbf7f82b7 .ai directory 2024-09-03 09:54:01 -04:00
Zach Gollwitzer
c05ee9b572 Remove unused settings temporarily (#1136) 2024-08-27 17:10:31 -04:00
Zach Gollwitzer
38c2b4670c Categories, tags, merchants, and menus improvements (#1135) 2024-08-27 17:06:41 -04:00
Zach Gollwitzer
f82ce59dad Fix merchants color picker (#1134)
* Fix merchants color picker

* Lint fixes
2024-08-26 19:18:27 -04:00
Zach Gollwitzer
166ed4b1ea Fix account transaction form resetting amount to 0 (#1133) 2024-08-26 19:10:17 -04:00
dependabot[bot]
0c0db44b7f Bump rails from 7.2.0 to 7.2.1 (#1130)
Bumps [rails](https://github.com/rails/rails) from 7.2.0 to 7.2.1.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.0...v7.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 10:38:46 -04:00
dependabot[bot]
cd254fd19b Bump aws-sdk-s3 from 1.158.0 to 1.159.0 (#1129)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.158.0 to 1.159.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 09:37:08 -04:00
dependabot[bot]
0d20be4905 Bump pagy from 9.0.5 to 9.0.6 (#1128)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.5 to 9.0.6.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.5...9.0.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 09:36:59 -04:00
Tony Vincent
cf861ccff9 Fix account sync when prices missing (#1127) 2024-08-26 09:36:27 -04:00
dependabot[bot]
525439e44d Bump vcr from 6.2.0 to 6.3.1 (#1131)
Bumps [vcr](https://github.com/vcr/vcr) from 6.2.0 to 6.3.1.
- [Release notes](https://github.com/vcr/vcr/releases)
- [Changelog](https://github.com/vcr/vcr/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vcr/vcr/compare/v6.2.0...v6.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 09:35:04 -04:00
Tony Vincent
e1efe97e6f Fix unable to create Deposit entries in investment portfolio (#1125)
* Fix unable to create Deposit entries in investment portfolio

* Add system test for deposit transaction
2024-08-25 17:48:46 -04:00
Zach Gollwitzer
52c729dc33 Bump to v0.1.0-alpha.16
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-23 10:39:14 -04:00
Zach Gollwitzer
de9723d63a Fix file upload UI opening twice (#1119)
* Fix file selector opening twice

* rename click() method

* Remove unused method

* Credit original author

Co-authored-by: Tony Yesudas <tonyvince7@gmail.com>

---------

Co-authored-by: Tony Yesudas <tonyvince7@gmail.com>
2024-08-23 10:30:08 -04:00
Zach Gollwitzer
eef4c2643b Rubocop updates (#1118)
* Minimal code style enforcement

* Formatting and lint code updates (no change in functionality)
2024-08-23 10:06:24 -04:00
Zach Gollwitzer
359bceb58e Vehicle view (#1117) 2024-08-23 09:33:42 -04:00
Zach Gollwitzer
e856691c86 Add Property Details View (#1116)
* Add backend for property account details

* Rubocop updates

* Add property form with details

* Revert "Rubocop updates"

This reverts commit 05b0b8f3a4.

* Bump brakeman to latest version

* Add overview section to property view

* Lint fixes
2024-08-23 08:47:08 -04:00
Zach Gollwitzer
4433488562 Fix holding name error (#1113)
* Add optional debugger to bin/dev script

* Fix holding naming
2024-08-20 17:35:23 -04:00
Zach Gollwitzer
37ae51f68a Fix query when account has zero income and expense (#1112)
* Reproduce error

* Apply fix

* Remove uneeded helper
2024-08-20 15:44:32 -04:00
dependabot[bot]
793a6027a3 Bump tailwindcss-rails from 2.7.2 to 2.7.3 (#1103)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.2 to 2.7.3.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.2...v2.7.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:42:18 -04:00
dependabot[bot]
4d20b5f2d4 Bump good_job from 4.1.1 to 4.2.0 (#1102)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.1.1 to 4.2.0.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.1.1...v4.2.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-08-19 09:42:02 -04:00
dependabot[bot]
7966c44d7f Bump propshaft from 0.9.0 to 0.9.1 (#1104)
Bumps [propshaft](https://github.com/rails/propshaft) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v0.9.0...v0.9.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:41:51 -04:00
dependabot[bot]
30b2ff7aa6 Bump ruby-lsp-rails from 0.3.12 to 0.3.13 (#1107)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.12 to 0.3.13.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.12...v0.3.13)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:26:01 -04:00
dependabot[bot]
f85fdba366 Bump aws-sdk-s3 from 1.157.0 to 1.158.0 (#1105)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.157.0 to 1.158.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:25:43 -04:00
dependabot[bot]
0cb4e968a0 Bump stimulus-rails from 1.3.3 to 1.3.4 (#1106)
Bumps [stimulus-rails](https://github.com/hotwired/stimulus-rails) from 1.3.3 to 1.3.4.
- [Release notes](https://github.com/hotwired/stimulus-rails/releases)
- [Commits](https://github.com/hotwired/stimulus-rails/compare/v1.3.3...v1.3.4)

---
updated-dependencies:
- dependency-name: stimulus-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 09:25:31 -04:00
dependabot[bot]
8ebf18e04d Bump sentry-ruby from 5.18.2 to 5.19.0 (#1108)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.18.2 to 5.19.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.18.2...5.19.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-08-19 09:25:16 -04:00
Pedro Carmona
0c1ff00c1e Refactor: Allow other import files (#1099)
* Rename stimulus controller

* feature: rename raw_csv_str to raw_file_str
2024-08-19 09:25:07 -04:00
457 changed files with 31367 additions and 3935 deletions

64
.ai/cursorrules.md Normal file
View File

@@ -0,0 +1,64 @@
<!-- Copy this file to .cursorrules in the root of the project on your local machine if you'd like to use these rules with Cursor. -->
You are an expert in Ruby, Ruby on Rails, Postgres, Tailwind, Stimulus, Hotwire and Turbo and always use the latest stable versions of those technologies.
**Code Style and Structure**
- Write concise, technical Ruby code with accurate examples.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., is_loading, has_error).
- Structure files: models, controllers, views, helpers, services, jobs, mailers.
**Naming Conventions**
- Use snake_case for file names and directories (e.g., app/models/user_profile.rb).
- Use CamelCase for classes and modules (e.g., UserProfile).
**Ruby on Rails Usage**
- Use Rails conventions for MVC structure.
- Favor scopes over class methods for queries.
- Use strong parameters for mass assignment protection.
- Use partials to DRY up views.
**Syntax and Formatting**
- Use two spaces for indentation.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use descriptive method names and keep methods short.
**Commenting Code**
- Write clear, concise comments to explain the purpose of individual functions and methods.
- Use comments to describe the intent and functionality of complex logic.
- Avoid redundant comments that state the obvious.
**UI and Styling**
- Use Tailwind CSS for styling.
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
- Use Stimulus for JavaScript behavior.
- Use Turbo for asynchronous actions and updates.
**Performance Optimization**
- Use eager loading to avoid N+1 queries.
- Cache expensive queries and partials where appropriate.
- Use background jobs for long-running tasks.
- Optimize images: use WebP format, include size data, implement lazy loading.
**Database Querying & Data Model Creation**
- Use ActiveRecord for data querying and model creation.
- Favor database constraints and indexes for data integrity and performance.
- Use migrations to manage schema changes.
**Key Conventions**
- Follow Rails best practices for RESTful routing.
- Optimize for performance and security.
- Use environment variables for configuration.
- Write tests for models, controllers, and features.
**AI Guidelines**
- Follow the users requirements carefully & to the letter.
- Confirm, then write code!
- Suggest solutions that I didn't think about—anticipate my needs
- Focus on readability over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Don't say things like "additional logic can be added here" — instead, add the logic.
- Be concise. Minimize any other prose.
- Consider new technologies and contrarian ideas, not just the conventional wisdom
- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make.

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2

1
.gitignore vendored
View File

@@ -59,3 +59,4 @@ compose-dev.yaml
gcp-storage-keyfile.json
coverage
.cursorrules

View File

@@ -1,12 +1,15 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
inherit_gem:
rubocop-rails-omakase: rubocop.yml
Layout/IndentationWidth:
Enabled: true
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
Layout/ElseAlignment:
Enabled: false
Layout/EndAlignment:
Enabled: false
Layout/IndentationStyle:
EnforcedStyle: spaces
IndentationWidth: 2
Layout/IndentationConsistency:
Enabled: true
Layout/SpaceInsidePercentLiteralDelimiters:
Enabled: true

View File

@@ -3,7 +3,7 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", "~> 7.2.0"
gem "rails", "~> 7.2.1"
# Drivers
gem "pg", "~> 1.5"
@@ -42,12 +42,12 @@ gem "inline_svg"
gem "octokit"
gem "pagy"
gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "tzinfo-data", platforms: %i[windows jruby]
gem "csv"
gem "redcarpet"
group :development, :test do
gem "debug", platforms: %i[ mri windows ]
gem "debug", platforms: %i[mri windows]
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
gem "i18n-tasks"

View File

@@ -8,29 +8,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
actionmailer (7.2.0)
actionpack (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activesupport (= 7.2.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0)
actionview (= 7.2.0)
activesupport (= 7.2.0)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -39,35 +39,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0)
actionpack (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0)
activesupport (= 7.2.0)
actionview (7.2.1)
activesupport (= 7.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0)
activesupport (= 7.2.0)
activejob (7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.3.6)
activemodel (7.2.0)
activesupport (= 7.2.0)
activerecord (7.2.0)
activemodel (= 7.2.0)
activesupport (= 7.2.0)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
timeout (>= 0.4.0)
activestorage (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activesupport (= 7.2.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
marcel (~> 1.0)
activesupport (7.2.0)
activesupport (7.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -82,20 +82,20 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.961.0)
aws-sdk-core (3.201.3)
aws-partitions (1.981.0)
aws-sdk-core (3.209.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-s3 (1.166.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-sigv4 (1.10.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
@@ -110,7 +110,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.1.2)
brakeman (6.2.1)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -136,9 +136,9 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.0)
dotenv (3.1.2)
dotenv-rails (3.1.2)
dotenv (= 3.1.2)
dotenv (3.1.4)
dotenv-rails (3.1.4)
dotenv (= 3.1.4)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.6.0)
@@ -153,10 +153,11 @@ GEM
tzinfo
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.10.1)
faraday-net_http (>= 2.0, < 3.2)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
json
logger
faraday-net_http (3.1.1)
faraday-net_http (3.3.0)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
@@ -166,12 +167,12 @@ GEM
ffi (1.17.0-x86-linux-gnu)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fugit (1.11.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.1.1)
good_job (4.3.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -180,11 +181,11 @@ GEM
thor (>= 1.0.0)
hashdiff (1.1.0)
highline (3.0.1)
hotwire-livereload (1.4.0)
hotwire-livereload (1.4.1)
actioncable (>= 6.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
i18n (1.14.5)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
@@ -203,11 +204,11 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
inline_svg (1.9.0)
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2)
irb (1.14.0)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
@@ -221,7 +222,7 @@ GEM
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.0)
logger (1.6.1)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -234,7 +235,7 @@ GEM
matrix (0.4.2)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.24.1)
minitest (5.25.1)
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
@@ -265,14 +266,14 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.0.5)
pagy (9.0.9)
parallel (1.25.1)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
pg (1.5.7)
prism (0.30.0)
propshaft (0.9.0)
pg (1.5.8)
prism (1.0.0)
propshaft (1.0.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@@ -280,7 +281,7 @@ GEM
psych (5.1.2)
stringio
public_suffix (5.1.0)
puma (6.4.2)
puma (6.4.3)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
@@ -292,20 +293,20 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.0)
actioncable (= 7.2.0)
actionmailbox (= 7.2.0)
actionmailer (= 7.2.0)
actionpack (= 7.2.0)
actiontext (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activemodel (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
bundler (>= 1.15.0)
railties (= 7.2.0)
railties (= 7.2.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -319,9 +320,9 @@ GEM
rails-settings-cached (2.9.4)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -330,18 +331,17 @@ GEM
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbs (3.5.2)
rbs (3.5.3)
logger
rdoc (6.7.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
regexp_parser (2.9.2)
reline (0.5.9)
reline (0.5.10)
io-console (~> 0.5)
rexml (3.3.4)
strscan
rexml (3.3.7)
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
@@ -371,13 +371,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.12)
ruby-lsp (0.18.1)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.31)
prism (~> 1.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.12)
ruby-lsp (>= 0.17.12, < 0.18.0)
ruby-lsp-rails (0.3.16)
ruby-lsp (>= 0.18.0, < 0.19.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -388,16 +388,16 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.3.1)
selenium-webdriver (4.23.0)
selenium-webdriver (4.25.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.18.2)
sentry-rails (5.20.1)
railties (>= 5.0)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
sentry-ruby (~> 5.20.1)
sentry-ruby (5.20.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -407,38 +407,37 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11518)
sorbet-runtime (0.5.11577)
stackprof (0.2.26)
stimulus-rails (1.3.3)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.7.2)
tailwindcss-rails (2.7.6)
railties (>= 7.0.0)
tailwindcss-rails (2.7.2-aarch64-linux)
tailwindcss-rails (2.7.6-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.2-arm-linux)
tailwindcss-rails (2.7.6-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.2-arm64-darwin)
tailwindcss-rails (2.7.6-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.2-x86_64-darwin)
tailwindcss-rails (2.7.6-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.2-x86_64-linux)
tailwindcss-rails (2.7.6-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
thor (1.3.2)
timeout (0.4.1)
turbo-rails (2.0.6)
turbo-rails (2.0.10)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
uri (0.13.0)
uri (0.13.1)
useragent (0.16.10)
vcr (6.2.0)
vcr (6.3.1)
base64
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
@@ -448,14 +447,14 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
webrick (1.8.2)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.17)
zeitwerk (2.6.18)
PLATFORMS
aarch64-linux
@@ -493,7 +492,7 @@ DEPENDENCIES
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails (~> 7.2.0)
rails (~> 7.2.1)
rails-settings-cached
redcarpet
rubocop-rails-omakase

View File

@@ -1,3 +1,3 @@
web: bin/rails server -b 0.0.0.0
web: ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
css: bin/rails tailwindcss:watch
worker: bundle exec good_job start

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -20,14 +20,14 @@
}
.form-field__label {
@apply block text-xs text-gray-500;
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
.form-field__input {
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:opacity-50;
@apply disabled:text-gray-400;
}
.form-field__radio {
@@ -35,7 +35,7 @@
}
.form-field__submit {
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked+label+.toggle-switch-dot {
@@ -63,12 +63,16 @@
}
select[multiple="multiple"] option {
@apply p-2 rounded-md;
@apply py-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@apply bg-gray-50;
@apply after:content-['\2713'] after:float-right after:text-gray-500;
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
}
select[multiple="multiple"] option:active,
select[multiple="multiple"] option:focus {
@apply bg-white;
}
.maybe-switch {
@@ -94,6 +98,26 @@
.tooltip {
@apply hidden absolute;
}
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
}
.btn--primary {
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
}
.btn--secondary {
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
}
.btn--outline {
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
}
.btn--ghost {
@apply border border-transparent text-gray-900 hover:bg-gray-50;
}
}
/* Small, single purpose classes that should take precedence over other styles */
@@ -110,4 +134,4 @@
.scrollbar::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}
}

View File

@@ -2,7 +2,7 @@ class Account::EntriesController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: %i[ edit update show destroy ]
before_action :set_entry, only: %i[edit update show destroy]
def edit
render entryable_view_path(:edit)

View File

@@ -8,7 +8,7 @@ class Account::TradesController < ApplicationController
end
def index
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[ Account::Trade Account::Transaction ])
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
end
def create

View File

@@ -12,8 +12,7 @@ class Account::TransactionsController < ApplicationController
end
def update
@entry.update!(entry_params.merge(amount: amount))
@entry.sync_account_later
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
@@ -34,7 +33,7 @@ class Account::TransactionsController < ApplicationController
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :entryable_type,
:name, :date, :amount, :currency, :entryable_type, :nature,
entryable_attributes: [
:id,
:notes,
@@ -43,14 +42,18 @@ class Account::TransactionsController < ApplicationController
:merchant_id,
{ tag_ids: [] }
]
)
end
).tap do |permitted_params|
nature = permitted_params.delete(:nature)
def amount
if params[:account_entry][:nature] == "income"
entry_params[:amount].to_d * -1
else
entry_params[:amount].to_d
end
if permitted_params[:amount]
amount_value = permitted_params[:amount].to_d
if nature == "income"
amount_value *= -1
end
permitted_params[:amount] = amount_value
end
end
end
end

View File

@@ -40,6 +40,6 @@ class Account::TransfersController < ApplicationController
end
def transfer_params
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name)
end
end

View File

@@ -12,7 +12,7 @@ class Account::ValuationsController < ApplicationController
if @entry.save
@entry.sync_account_later
redirect_to account_valuations_path(@account), notice: t(".success")
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)

View File

@@ -2,7 +2,7 @@ class AccountsController < ApplicationController
layout :with_sidebar
include Filterable
before_action :set_account, only: %i[ edit show destroy sync update ]
before_action :set_account, only: %i[edit show destroy sync update]
def index
@institutions = Current.family.institutions
@@ -23,7 +23,12 @@ class AccountsController < ApplicationController
end
def new
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
@account = Account.new(
accountable: Accountable.from_type(params[:type])&.new,
currency: Current.family.currency
)
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
if params[:institution_id]
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
@@ -56,8 +61,6 @@ class AccountsController < ApplicationController
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

View File

@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include AutoSync, Authentication, Invitable, SelfHostable
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
include Pagy::Backend
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.

View File

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

View File

@@ -6,17 +6,17 @@ class Category::DropdownsController < ApplicationController
end
private
def set_from_params
if params[:category_id]
@selected_category = categories_scope.find(params[:category_id])
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
if params[:transaction_id]
@transaction = Current.family.transactions.find(params[:transaction_id])
def categories_scope
Current.family.categories.alphabetically
end
end
def categories_scope
Current.family.categories.alphabetically
end
end

View File

@@ -2,6 +2,7 @@ module Authentication
extend ActiveSupport::Concern
included do
before_action :set_request_details
before_action :authenticate_user!
end
@@ -12,28 +13,30 @@ module Authentication
end
private
def authenticate_user!
if user = User.find_by(id: session[:user_id])
Current.user = user
else
redirect_to new_session_url
def authenticate_user!
if session_record = Session.find_by_id(cookies.signed[:session_token])
Current.session = session_record
else
if self_hosted_first_login?
redirect_to new_registration_url
else
redirect_to new_session_url
end
end
end
end
def login(user)
Current.user = user
reset_session
session[:user_id] = user.id
set_last_login_at
end
def create_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }
session
end
def logout
Current.user = nil
reset_session
end
def self_hosted_first_login?
Rails.application.config.app_mode.self_hosted? && User.count.zero?
end
def set_last_login_at
Current.user.update(last_login_at: DateTime.now)
end
def set_request_details
Current.user_agent = request.user_agent
Current.ip_address = request.ip
end
end

View File

@@ -1,9 +1,9 @@
module Filterable
extend ActiveSupport::Concern
extend ActiveSupport::Concern
included do
before_action :set_period
end
included do
before_action :set_period
end
private

View File

@@ -7,6 +7,10 @@ module Invitable
private
def invite_code_required?
ENV["REQUIRE_INVITE_CODE"] == "true"
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
end
def self_hosted?
Rails.application.config.app_mode.self_hosted?
end
end

View File

@@ -0,0 +1,13 @@
module Localize
extend ActiveSupport::Concern
included do
around_action :switch_locale
end
private
def switch_locale(&action)
locale = Current.family.try(:locale) || I18n.default_locale
I18n.with_locale(locale, &action)
end
end

View File

@@ -2,11 +2,15 @@ module SelfHostable
extend ActiveSupport::Concern
included do
helper_method :self_hosted?
helper_method :self_hosted?, :self_hosted_first_login?
end
private
def self_hosted?
Rails.configuration.app_mode.self_hosted?
end
def self_hosted_first_login?
self_hosted? && User.count.zero?
end
end

View File

@@ -0,0 +1,31 @@
module StoreLocation
extend ActiveSupport::Concern
included do
helper_method :previous_path
before_action :store_return_to
after_action :clear_previous_path
end
def previous_path
session[:return_to] || fallback_path
end
private
def store_return_to
if params[:return_to].present?
session[:return_to] = params[:return_to]
end
end
def clear_previous_path
if request.fullpath == session[:return_to]
session.delete(:return_to)
end
end
def fallback_path
root_path
end
end

View File

@@ -0,0 +1,22 @@
class Import::CleansController < ApplicationController
layout "imports"
before_action :set_import
def show
redirect_to import_configuration_path(@import), alert: "Please configure your import before proceeding." unless @import.configured?
rows = @import.rows.ordered
if params[:view] == "errors"
rows = rows.reject { |row| row.valid? }
end
@pagy, @rows = pagy_array(rows, limit: params[:per_page] || "10")
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
end

View File

@@ -0,0 +1,25 @@
class Import::ConfigurationsController < ApplicationController
layout "imports"
before_action :set_import
def show
end
def update
@import.update!(import_params)
@import.generate_rows_from_csv
@import.reload.sync_mappings
redirect_to import_clean_path(@import), notice: "Import configured successfully."
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
def import_params
params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label)
end
end

View File

@@ -0,0 +1,14 @@
class Import::ConfirmsController < ApplicationController
layout "imports"
before_action :set_import
def show
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
end

View File

@@ -0,0 +1,43 @@
class Import::MappingsController < ApplicationController
before_action :set_import
def update
mapping = @import.mappings.find(params[:id])
mapping.update! \
create_when_empty: create_when_empty,
mappable: mappable,
value: mapping_params[:value]
redirect_back_or_to import_confirm_path(@import)
end
private
def mapping_params
params.require(:import_mapping).permit(:type, :key, :mappable_id, :mappable_type, :value)
end
def set_import
@import = Current.family.imports.find(params[:import_id])
end
def mappable
return nil unless mappable_class.present?
@mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family)
end
def create_when_empty
return false unless mapping_class.present?
mapping_params[:mappable_id] == mapping_class::CREATE_NEW_KEY
end
def mappable_class
mapping_params[:mappable_type]&.constantize
end
def mapping_class
mapping_params[:type]&.constantize
end
end

View File

@@ -0,0 +1,24 @@
class Import::RowsController < ApplicationController
before_action :set_import_row
def update
@row.assign_attributes(row_params)
@row.save!(validate: false)
@row.sync_mappings
redirect_to import_row_path(@row.import, @row)
end
def show
end
private
def row_params
params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes)
end
def set_import_row
@import = Current.family.imports.find(params[:import_id])
@row = @import.rows.find(params[:id])
end
end

View File

@@ -0,0 +1,47 @@
class Import::UploadsController < ApplicationController
layout "imports"
before_action :set_import
def show
end
def update
if csv_valid?(csv_str)
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
@import.save!(validate: false)
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
else
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
render :show, status: :unprocessable_entity
end
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
def csv_str
@csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str]
end
def csv_valid?(str)
require "csv"
begin
csv = CSV.parse(str || "", headers: true)
return false if csv.headers.empty?
return false if csv.count == 0
true
rescue CSV::MalformedCSVError
false
end
end
def upload_params
params.require(:import).permit(:raw_file_str, :csv_file, :col_sep)
end
end

View File

@@ -1,118 +1,44 @@
require "ostruct"
class ImportsController < ApplicationController
before_action :set_import, except: %i[ index new create ]
before_action :set_import, only: %i[show publish destroy]
def publish
@import.publish_later
redirect_to import_path(@import), notice: "Your import has started in the background."
end
def index
@imports = Current.family.imports
render layout: "with_sidebar"
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, col_sep: params[:import][:col_sep]
redirect_to load_import_path(@import), notice: t(".import_updated")
@pending_import = Current.family.imports.ordered.pending.first
end
def create
account = Current.family.accounts.find(params[:import][:account_id])
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
import = Current.family.imports.create! import_params
redirect_to load_import_path(@import), notice: t(".import_created")
redirect_to import_upload_path(import)
end
def show
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable?
end
def destroy
@import.destroy!
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
end
@import.destroy
def load
end
def upload_csv
begin
@import.raw_csv_str = import_params[:raw_csv_str].read
rescue NoMethodError
flash.now[:alert] = "Please select a file to upload"
render :load, status: :unprocessable_entity and return
end
if @import.save
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:alert] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
end
def load_csv
if @import.update(import_params)
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:alert] = @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
redirect_to imports_path, notice: "Your import has been deleted."
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 ])
def import_params
params.require(:import).permit(:type)
end
end

View File

@@ -1,5 +1,5 @@
class InstitutionsController < ApplicationController
before_action :set_institution, except: %i[ new create ]
before_action :set_institution, except: %i[new create]
def new
@institution = Institution.new
@@ -23,6 +23,11 @@ class InstitutionsController < ApplicationController
redirect_to accounts_path, notice: t(".success")
end
def sync
@institution.sync
redirect_back_or_to accounts_path, notice: t(".success")
end
private
def institution_params

View File

@@ -0,0 +1,10 @@
class InviteCodesController < ApplicationController
def index
@invite_codes = InviteCode.all
end
def create
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
end
end

View File

@@ -1,7 +1,7 @@
class MerchantsController < ApplicationController
layout :with_sidebar
before_action :set_merchant, only: %i[ edit update destroy ]
before_action :set_merchant, only: %i[edit update destroy]
def index
@merchants = Current.family.merchants.alphabetically
@@ -31,11 +31,11 @@ class MerchantsController < ApplicationController
private
def set_merchant
@merchant = Current.family.merchants.find(params[:id])
end
def set_merchant
@merchant = Current.family.merchants.find(params[:id])
end
def merchant_params
params.require(:merchant).permit(:name, :color)
end
def merchant_params
params.require(:merchant).permit(:name, :color)
end
end

View File

@@ -31,7 +31,7 @@ class PagesController < ApplicationController
end
def changelog
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
@release_notes = Provider::Github.new.fetch_latest_release_notes
end
def feedback

View File

@@ -3,7 +3,7 @@ class PasswordResetsController < ApplicationController
layout "auth"
before_action :set_user_by_token, only: %i[ edit update ]
before_action :set_user_by_token, only: %i[edit update]
def new
end
@@ -33,12 +33,12 @@ class PasswordResetsController < ApplicationController
private
def set_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token])
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
end
def set_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token])
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
end
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end

View File

@@ -12,7 +12,7 @@ class PasswordsController < ApplicationController
private
def password_params
params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
end
def password_params
params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
end
end

View File

@@ -0,0 +1,41 @@
class PropertiesController < ApplicationController
before_action :set_account, only: :update
def create
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_to account, notice: t(".success")
end
def update
@account.update!(account_params)
@account.sync_later
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:year_built,
:area_unit,
:area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
]
)
end
end

View File

@@ -17,7 +17,7 @@ class RegistrationsController < ApplicationController
if @user.save
Category.create_default_categories(@user.family)
login @user
@session = create_session_for(@user)
flash[:notice] = t(".success")
redirect_to root_path
else
@@ -28,17 +28,17 @@ class RegistrationsController < ApplicationController
private
def set_user
@user = User.new user_params.except(:invite_code)
end
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
end
def claim_invite_code
unless InviteCode.claim! params[:user][:invite_code]
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
def set_user
@user = User.new user_params.except(:invite_code)
end
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
end
def claim_invite_code
unless InviteCode.claim! params[:user][:invite_code]
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
end
end
end
end

View File

@@ -1,4 +1,5 @@
class SessionsController < ApplicationController
before_action :set_session, only: :destroy
skip_authentication only: %i[new create]
layout "auth"
@@ -8,7 +9,7 @@ class SessionsController < ApplicationController
def create
if user = User.authenticate_by(email: params[:email], password: params[:password])
login user
@session = create_session_for(user)
redirect_to root_path
else
flash.now[:alert] = t(".invalid_credentials")
@@ -17,7 +18,12 @@ class SessionsController < ApplicationController
end
def destroy
logout
@session.destroy
redirect_to root_path, notice: t(".logout_successful")
end
private
def set_session
@session = Current.user.sessions.find(params[:id])
end
end

View File

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

View File

@@ -1,71 +1,43 @@
class Settings::HostingsController < SettingsController
before_action :verify_hosting_mode
before_action :raise_if_not_self_hosted
def show
@synth_usage = Current.family.synth_usage
end
def update
if all_updates_valid?
hosting_params.keys.each do |key|
Setting.send("#{key}=", hosting_params[key].strip)
end
if hosting_params[:upgrades_setting].present?
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
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[:alert] = t(".missing_smtp_setting_error")
render(:show, status: :unprocessable_entity)
return
Setting.upgrades_mode = mode
Setting.upgrades_target = target
end
begin
NotificationMailer.with(user: Current.user).test_email.deliver_now
rescue => _e
flash[:alert] = t(".error")
render :show, status: :unprocessable_entity
return
if hosting_params.key?(:render_deploy_hook)
Setting.render_deploy_hook = hosting_params[:render_deploy_hook]
end
if hosting_params.key?(:require_invite_for_signup)
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
end
if hosting_params.key?(:synth_api_key)
Setting.synth_api_key = hosting_params[:synth_api_key]
end
redirect_to settings_hosting_path, notice: t(".success")
rescue ActiveRecord::RecordInvalid => error
flash.now[:alert] = t(".failure")
render :show, status: :unprocessable_entity
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
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
end
def verify_hosting_mode
head :not_found unless self_hosted?
def raise_if_not_self_hosted
raise "Settings not available on non-self-hosted instance" unless self_hosted?
end
end

View File

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

View File

@@ -21,6 +21,6 @@ class Settings::PreferencesController < SettingsController
private
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency ])
params.require(:user).permit(family_attributes: [ :id, :currency, :locale ])
end
end

View File

@@ -23,7 +23,7 @@ class Settings::ProfilesController < SettingsController
def destroy
if Current.user.deactivate
logout
Current.session.destroy
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
@@ -31,9 +31,8 @@ class Settings::ProfilesController < SettingsController
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])
end
def user_params
params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])
end
end

View File

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

View File

@@ -1,7 +1,7 @@
class TagsController < ApplicationController
layout :with_sidebar
before_action :set_tag, only: %i[ edit update ]
before_action :set_tag, only: %i[edit update]
def index
@tags = Current.family.tags.alphabetically

View File

@@ -17,6 +17,9 @@ class TransactionsController < ApplicationController
@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])
e.currency = e.account.currency
else
e.currency = Current.family.currency
end
end
end
@@ -67,9 +70,6 @@ class TransactionsController < ApplicationController
redirect_back_or_to transactions_url, notice: t(".success")
end
def rules
end
private
def amount
@@ -93,7 +93,8 @@ class TransactionsController < ApplicationController
end
def search_params
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
params.fetch(:q, {})
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
end
def transaction_entry_params

View File

@@ -0,0 +1,42 @@
class VehiclesController < ApplicationController
before_action :set_account, only: :update
def create
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_to account, notice: t(".success")
end
def update
@account.update!(account_params)
@account.sync_later
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:make,
:model,
:year,
:mileage_value,
:mileage_unit
]
)
end
end

View File

@@ -23,13 +23,39 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
def account_tabs(account)
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) }
# Eventually, we'll have an accountable form for each type of accountable, so
# this helper is a convenience for now to reuse common logic in the accounts controller
def new_account_form_url(account)
case account.accountable_type
when "Property"
properties_path
when "Vehicle"
vehicles_path
else
accounts_path
end
end
def edit_account_form_url(account)
case account.accountable_type
when "Property"
property_path(account)
when "Vehicle"
vehicle_path(account)
else
account_path(account)
end
end
def account_tabs(account)
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
[ value_tab, transactions_tab ]
@@ -43,6 +69,11 @@ module AccountsHelper
tab || available_tabs.first
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
private
def class_mapping(accountable_type)

View File

@@ -57,9 +57,9 @@ module ApplicationHelper
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
def disclosure(title, &block)
content = capture &block
render partial: "shared/disclosure", locals: { title: title, content: content }
end
def sidebar_link_to(name, path, options = {})
@@ -138,13 +138,13 @@ module ApplicationHelper
def format_money(number_or_money, options = {})
money = Money.new(number_or_money)
options.reverse_merge!(money.default_format_options)
options.reverse_merge!(money.format_options(I18n.locale))
number_to_currency(money.amount, options)
end
def format_money_without_symbol(number_or_money, options = {})
money = Money.new(number_or_money)
options.reverse_merge!(money.default_format_options)
options.reverse_merge!(money.format_options(I18n.locale))
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
end

View File

@@ -1,6 +0,0 @@
module AuthMessagesHelper
def auth_messages(form = nil)
render "shared/auth_messages", flash: flash,
errors: form&.object&.errors&.full_messages || []
end
end

View File

@@ -10,11 +10,6 @@ module FormsHelper
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
end
def form_field_tag(options = {}, &block)
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
tag.div(**options, &block)
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:)
@@ -27,55 +22,11 @@ module FormsHelper
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
end
def money_with_currency_field(form, money_method, options = {})
render partial: "shared/money_field", locals: {
form: form,
money_method: money_method,
default_currency: options[:default_currency] || "USD",
disable_currency: options[:disable_currency] || false,
hide_currency: options[:hide_currency] || false,
label: options[:label] || "Amount"
}
end
def money_field(form, method, options = {})
value = form.object ? form.object.send(method) : nil
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
# See "Monetizable" concern
money_amount_method = method.to_s.chomp("_money").to_sym
money_options = {
value: value&.amount,
placeholder: "100",
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = options.merge(money_options)
form.number_field money_amount_method, merged_options
end
def currency_select_full(form, method, options = {}, html_options = {}, &block)
choices = currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }
form.select method, choices, options, html_options, &block
end
def currency_select(form, method, options = {}, html_options = {}, &block)
choices = currencies_for_select.map(&:iso_code)
form.select method, choices, options, html_options, &block
def currencies_for_select
Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }
end
private
def currencies_for_select
Money::Currency.all_instances
.sort_by(&:priority)
end
def radio_tab_contents(label:, icon:)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
concat lucide_icon(icon, class: "w-5 h-5")

View File

@@ -1,19 +1,63 @@
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
""
def mapping_label(mapping_class)
{
"Import::AccountTypeMapping" => "Account Type",
"Import::AccountMapping" => "Account",
"Import::CategoryMapping" => "Category",
"Import::TagMapping" => "Tag"
}.fetch(mapping_class.name)
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 }
]
def import_col_label(key)
{
date: "Date",
amount: "Amount",
name: "Name",
currency: "Currency",
category: "Category",
tags: "Tags",
account: "Account",
notes: "Notes",
qty: "Quantity",
ticker: "Ticker",
price: "Price",
entity_type: "Type"
}[key]
end
def dry_run_resource(key)
map = {
transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"),
accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"),
categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"),
tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5")
}
map[key]
end
def permitted_import_configuration_path(import)
if permitted_import_types.include?(import.type.underscore)
"import/configurations/#{import.type.underscore}"
else
raise "Unknown import type: #{import.type}"
end
end
def cell_class(row, field)
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400"
row.valid? # populate errors
border = row.errors.key?(field) ? "border-red-500" : "border-transparent"
[ base, border ].join(" ")
end
private
def permitted_import_types
%w[transaction_import trade_import account_import mint_import]
end
DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true)
end

View File

@@ -1,12 +1,12 @@
module MenusHelper
def contextual_menu(&block)
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
tag.div 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)
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
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"))
@@ -25,13 +25,14 @@ module MenusHelper
private
def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", 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
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
data: { menu_target: "content" } do
capture(&block)
end
end

View File

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

View File

@@ -1,14 +1,46 @@
module SettingsHelper
def next_setting(title, path)
render partial: "settings/nav_link_large", locals: { path: path, direction: "next", title: title }
end
SETTINGS_ORDER = [
{ name: I18n.t("settings.nav.profile_label"), path: :settings_profile_path },
{ name: I18n.t("settings.nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
{ name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
{ name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
]
def previous_setting(title, path)
render partial: "settings/nav_link_large", locals: { path: path, direction: "previous", title: title }
def adjacent_setting(current_path, offset)
visible_settings = SETTINGS_ORDER.select { |setting| setting[:condition].nil? || send(setting[:condition]) }
current_index = visible_settings.index { |setting| send(setting[:path]) == current_path }
return nil unless current_index
adjacent_index = current_index + offset
return nil if adjacent_index < 0 || adjacent_index >= visible_settings.size
adjacent = visible_settings[adjacent_index]
render partial: "settings/nav_link_large", locals: {
path: send(adjacent[:path]),
direction: offset > 0 ? "next" : "previous",
title: adjacent[:name]
}
end
def settings_section(title:, subtitle: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content }
end
def settings_nav_footer
previous_setting = adjacent_setting(request.path, -1)
next_setting = adjacent_setting(request.path, 1)
content_tag :div, class: "flex justify-between gap-4" do
concat(previous_setting)
concat(next_setting)
end
end
end

View File

@@ -6,53 +6,69 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
text_field_helpers.each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
input_html = label_html(method, options) + super(method, merged_options(options))
input_html = apply_form_field_wrapper(input_html) unless options[:inline]
input_html
merged_options = { class: "form-field__input" }.merge(options)
label = build_label(method, options)
field = super(method, merged_options)
build_styled_field(label, field, merged_options)
end
RUBY_EVAL
end
def radio_button(method, tag_value, options = {})
super(method, tag_value, merged_options(options, "form-field__radio"))
merged_options = { class: "form-field__radio" }.merge(options)
super(method, tag_value, merged_options)
end
def select(method, choices, options = {}, html_options = {})
input_html = label_html(method, options) + super(method, choices, options, merged_options(html_options))
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
input_html
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
input_html = label_html(method, options) + super(method, collection, value_method, text_method, options, merged_options(html_options))
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
input_html
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
end
def money_field(amount_method, currency_method, options = {})
@template.render partial: "shared/money_field", locals: {
form: self,
amount_method:,
currency_method:,
**options
}
end
def submit(value = nil, options = {})
merged_options = { class: "btn btn--primary w-full" }.merge(options)
value, options = nil, value if value.is_a?(Hash)
super(value, merged_options(options, "form-field__submit"))
super(value, merged_options)
end
private
def apply_form_field_wrapper(input_html, **options)
@template.form_field_tag(**options) do
input_html
def build_styled_field(label, field, options, remove_padding_right: false)
if options[:inline]
label + field
else
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
label + field
end
end
end
def merged_options(options, default_class = "form-field__input")
combined_classes = options.fetch(:class, "") + " #{default_class}"
style_options = { class: combined_classes }
non_custom_options = options.except(:class, :label, :inline)
style_options.merge(non_custom_options)
end
def label_html(method, options)
return label(method, class: "form-field__label") if options[:label] == true
def build_label(method, options)
return "".html_safe unless options[:label]
return label(method, class: "form-field__label") if options[:label] == true
label(method, options[:label], class: "form-field__label")
end
end

View File

@@ -1,12 +1,13 @@
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" }
{ key: "account_filter", icon: "layers" },
{ key: "date_filter", icon: "calendar" },
{ key: "type_filter", icon: "tag" },
{ key: "amount_filter", icon: "hash" },
{ key: "category_filter", icon: "shapes" },
{ key: "tag_filter", icon: "tags" },
{ key: "merchant_filter", icon: "store" }
]
end

View File

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

View File

@@ -0,0 +1,28 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source", "iconDefault", "iconSuccess"]
copy(event) {
event.preventDefault();
if (this.sourceTarget && this.sourceTarget.textContent) {
navigator.clipboard.writeText(this.sourceTarget.textContent)
.then(() => {
this.showSuccess();
})
.catch((error) => {
console.error('Failed to copy text: ', error);
});
}
}
showSuccess() {
this.iconDefaultTarget.classList.add('hidden');
this.iconSuccessTarget.classList.remove('hidden');
setTimeout(() => {
this.iconDefaultTarget.classList.remove('hidden');
this.iconSuccessTarget.classList.add('hidden');
}, 3000);
}
}

View File

@@ -1,32 +1,29 @@
import {Controller} from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="merchant-avatar"
// Connects to data-controller="color-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) => {
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

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

View File

@@ -1,61 +1,57 @@
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
*
* - If you need a form-enabled "select" element, use the "listbox" controller instead.
*/
export default class extends Controller {
static targets = [
"button",
"content",
"submenu",
"submenuButton",
"submenuContent",
];
static targets = ["button", "content"];
static values = {
show: { type: Boolean, default: false },
showSubmenu: { type: Boolean, default: false },
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
};
initialize() {
connect() {
this.show = this.showValue;
this.showSubmenu = this.showSubmenuValue;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
this.startAutoUpdate();
}
connect() {
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
this.close();
}
addEventListeners() {
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
disconnect() {
this.element.removeEventListener("keydown", this.handleKeydown);
removeEventListeners() {
this.buttonTarget.removeEventListener("click", this.toggle);
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
this.close();
}
// If turbo reloads, we maintain the state of the menu
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) {
this.close();
}
if (this.show && !this.element.contains(event.target)) this.close();
};
handleKeydown = (event) => {
switch (event.key) {
case "Escape":
this.close();
this.buttonTarget.focus(); // Bring focus back to the button
break;
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
}
};
@@ -63,6 +59,7 @@ export default class extends Controller {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
if (this.show) {
this.update();
this.focusFirstElement();
}
};
@@ -73,12 +70,40 @@ export default class extends Controller {
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
computePosition(this.buttonTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [
offset(this.offsetValue),
flip(),
shift({ padding: 5 })
],
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
position: 'fixed',
left: `${x}px`,
top: `${y}px`,
});
});
}
}

View File

@@ -13,7 +13,6 @@ export default class extends Controller {
updateAmount(currency) {
(new CurrenciesService).get(currency).then((currency) => {
console.log(currency)
this.amountTarget.step = currency.step;
if (isFinite(this.amountTarget.value)) {

View File

@@ -1,16 +1,3 @@
class ApplicationMailer < ActionMailer::Base
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

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

@@ -5,14 +5,15 @@ class Account < ApplicationRecord
belongs_to :family
belongs_to :institution, optional: true
belongs_to :import, optional: true
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
@@ -28,6 +29,8 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
accepts_nested_attributes_for :accountable
delegate :value, :series, to: :accountable
class << self
@@ -51,27 +54,28 @@ class Account < ApplicationRecord
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
transaction do
attributes[:accountable_attributes] ||= {} # Ensure accountable is created
account = new(attributes)
# 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?
# Always initialize an account with a valuation entry to begin tracking value history
account.entries.build \
date: start_date,
amount: start_balance,
date: Date.current,
amount: account.balance,
currency: account.currency,
entryable: Account::Valuation.new
end
account.save!
account
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
end

View File

@@ -1,9 +1,9 @@
class Account::Balance < ApplicationRecord
include Monetizable
include Monetizable
belongs_to :account
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) }
belongs_to :account
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

@@ -14,7 +14,8 @@ class Account::Balance::Syncer
if daily_balances.any?
account.reload
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
last_balance = daily_balances.select { |db| db.currency == account.currency }.last&.balance
account.update! balance: last_balance
end
end
rescue Money::ConversionError => e
@@ -102,7 +103,7 @@ class Account::Balance::Syncer
end
def find_prior_balance
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
account.balances.where(currency: account.currency).where("date < ?", sync_start_date).order(date: :desc).first&.balance
end
def net_entry_flows(entries, target_currency = account.currency)

View File

@@ -5,6 +5,7 @@ class Account::Entry < ApplicationRecord
belongs_to :account
belongs_to :transfer, optional: true
belongs_to :import, optional: true
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
@@ -12,7 +13,6 @@ class Account::Entry < ApplicationRecord
validates :date, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
validate :trade_valid?, if: -> { account_trade? }
scope :chronological, -> { order(:date, :created_at) }
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
@@ -148,6 +148,27 @@ class Account::Entry < ApplicationRecord
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[:types].present?
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
if params[:types].include?("income") && !params[:types].include?("expense")
query = query.where("account_entries.amount < 0")
elsif params[:types].include?("expense") && !params[:types].include?("income")
query = query.where("account_entries.amount >= 0")
end
end
if params[:amount].present? && params[:amount_operator].present?
case params[:amount_operator]
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
when "less"
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
when "greater"
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
end
end
if params[:accounts].present? || params[:account_ids].present?
query = query.joins(:account)
end
@@ -198,21 +219,4 @@ class Account::Entry < ApplicationRecord
previous: previous_entry&.amount_money,
favorable_direction: account.favorable_direction
end
def trade_valid?
if account_trade.sell?
current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs
# i18n-tasks-use t('activerecord.errors.models.account/entry.attributes.base.invalid_sell_quantity')
errors.add(
:base,
:invalid_sell_quantity,
sell_qty: account_trade.qty.abs,
ticker: account_trade.security.ticker,
current_qty: current_qty
)
end
end
end
end

View File

@@ -1,7 +1,7 @@
class Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[ income expense buy sell interest transfer_in transfer_out ].freeze
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id

View File

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

View File

@@ -14,9 +14,12 @@ class Account::Holding < ApplicationRecord
scope :known_value, -> { where.not(amount: nil) }
scope :for, ->(security) { where(security_id: security).order(:date) }
delegate :name, to: :security
delegate :ticker, to: :security
def name
security.name || ticker
end
def weight
return nil unless amount

View File

@@ -48,7 +48,9 @@ class Account::Holding::Syncer
end
ticker_start_dates.each do |ticker, date|
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
fetched_prices = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: date, end_date: Date.current, cache: false).run
prices[ticker] = gapfilled_prices
end
prices

View File

@@ -1,7 +1,7 @@
class Account::TradeBuilder < Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[ buy sell ].freeze
TYPES = %w[buy sell].freeze
attr_accessor :type, :qty, :price, :ticker, :date, :account

View File

@@ -13,8 +13,15 @@ class Account::Transaction < ApplicationRecord
class << self
def search(params)
query = all
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
query = query.joins(:category).where(categories: { name: params[:categories] }) if params[:categories].present?
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
if params[:tags].present?
query = query.joins(:tags)
.where(tags: { name: params[:tags] })
.distinct
end
query
end
@@ -25,7 +32,7 @@ class Account::Transaction < ApplicationRecord
private
def searchable_keys
%i[ categories merchants ]
%i[categories merchants tags]
end
end

View File

@@ -1,7 +1,7 @@
class Account::TransactionBuilder
include ActiveModel::Model
TYPES = %w[ income expense interest transfer_in transfer_out ].freeze
TYPES = %w[income expense interest transfer_in transfer_out].freeze
attr_accessor :type, :amount, :date, :account, :transfer_account_id
@@ -21,7 +21,7 @@ class Account::TransactionBuilder
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) unless transfer_account_id
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id

View File

@@ -46,7 +46,7 @@ class Account::Transfer < ApplicationRecord
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
outflow = from_account.entries.build \
amount: amount.abs,
currency: currency,
currency: from_account.currency,
date: date,
name: name,
marked_as_transfer: true,
@@ -54,7 +54,7 @@ class Account::Transfer < ApplicationRecord
inflow = to_account.entries.build \
amount: amount.abs * -1,
currency: currency,
currency: from_account.currency,
date: date,
name: name,
marked_as_transfer: true,
@@ -72,27 +72,23 @@ class Account::Transfer < ApplicationRecord
def transaction_count
unless entries.size == 2
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_exactly_2_entries')
errors.add :entries, :must_have_exactly_2_entries
end
end
def from_different_accounts
accounts = entries.map { |e| e.account_id }.uniq
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_from_different_accounts')
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
end
def net_zero_flows
unless entries.sum(&:amount).zero?
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_an_inflow_and_outflow_that_net_to_zero')
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
end
end
def all_transactions_marked
unless entries.all?(&:marked_as_transfer)
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_marked_as_transfer')
errors.add :entries, :must_be_marked_as_transfer
end
end

View File

@@ -0,0 +1,49 @@
class AccountImport < Import
def import!
transaction do
rows.each do |row|
mapping = mappings.account_types.find_by(key: row.entity_type)
accountable_class = mapping.value.constantize
account = family.accounts.build(
name: row.name,
balance: row.amount.to_d,
currency: row.currency,
accountable: accountable_class.new,
import: self
)
account.save!
end
end
end
def mapping_steps
[ Import::AccountTypeMapping ]
end
def required_column_keys
%i[name amount]
end
def column_keys
%i[entity_type name amount currency]
end
def dry_run
{
accounts: rows.count
}
end
def csv_template
template = <<-CSV
Account type*,Name*,Balance*,Currency
Checking,Main Checking Account,1000.00,USD
Savings,Emergency Fund,5000.00,USD
Credit Card,Rewards Card,-500.00,USD
CSV
CSV.parse(template, headers: true)
end
end

24
app/models/address.rb Normal file
View File

@@ -0,0 +1,24 @@
class Address < ApplicationRecord
belongs_to :addressable, polymorphic: true
validates :line1, :locality, presence: true
validates :postal_code, presence: true, if: :postal_code_required?
def to_s
I18n.t("address.format",
line1: line1,
line2: line2,
county: county,
locality: locality,
region: region,
country: country,
postal_code: postal_code
)
end
private
def postal_code_required?
country.in?(%w[US CA GB])
end
end

View File

@@ -1,5 +1,6 @@
class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
belongs_to :family
validates :name, :color, :family, presence: true
@@ -47,7 +48,7 @@ class Category < ApplicationRecord
private
def clear_internal_category
self.internal_category = nil
end
def clear_internal_category
self.internal_category = nil
end
end

View File

@@ -1,8 +1,8 @@
module Accountable
extend ActiveSupport::Concern
ASSET_TYPES = %w[ Depository Investment Crypto Property Vehicle OtherAsset ]
LIABILITY_TYPES = %w[ CreditCard Loan OtherLiability ]
ASSET_TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset]
LIABILITY_TYPES = %w[CreditCard Loan OtherLiability]
TYPES = ASSET_TYPES + LIABILITY_TYPES
def self.from_type(type)

View File

@@ -1,14 +1,14 @@
module Monetizable
extend ActiveSupport::Concern
extend ActiveSupport::Concern
class_methods do
def monetize(*fields)
fields.each do |field|
define_method("#{field}_money") do
value = self.send(field)
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
end
class_methods do
def monetize(*fields)
fields.each do |field|
define_method("#{field}_money") do
value = self.send(field)
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
end
end
end
end
end

View File

@@ -18,13 +18,12 @@ module Providable
Provider::Github.new
end
def synth_provider
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
private
def synth_provider
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
def self_hosted?
Rails.application.config.app_mode.self_hosted?
end

View File

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

View File

@@ -6,6 +6,7 @@ class Demo::Generator
end
def reset_and_clear_data!
reset_settings!
clear_data!
create_user!
@@ -14,6 +15,7 @@ class Demo::Generator
def reset_data!
Family.transaction do
reset_settings!
clear_data!
create_user!
@@ -52,17 +54,23 @@ class Demo::Generator
end
def clear_data!
InviteCode.destroy_all
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
end
def reset_settings!
Setting.destroy_all
end
def create_user!
family.users.create! \
email: "user@maybe.local",
first_name: "Demo",
last_name: "User",
role: "admin",
password: "password"
end

View File

@@ -1,15 +1,19 @@
class Family < ApplicationRecord
include Providable
has_many :users, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :transactions, through: :accounts
has_many :entries, through: :accounts
has_many :imports, through: :accounts
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
@@ -46,7 +50,8 @@ class Family < ApplicationRecord
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.marked_as_transfer = ?", false)
.where("account_entries.entryable_type = ?", "Account::Transaction")
.group("id")
.group("accounts.id")
.having("SUM(ABS(account_entries.amount)) > 0")
.to_a
results.each do |r|
@@ -118,4 +123,8 @@ class Family < ApplicationRecord
def needs_sync?
last_synced_at.nil? || last_synced_at.to_date < Date.current
end
def synth_usage
self.class.synth_provider&.usage
end
end

44
app/models/gapfiller.rb Normal file
View File

@@ -0,0 +1,44 @@
class Gapfiller
attr_reader :series
def initialize(series, start_date:, end_date:, cache:)
@series = series
@date_range = start_date..end_date
@cache = cache
end
def run
gapfilled_records = []
date_range.each do |date|
record = series.find { |r| r.date == date }
if should_gapfill?(date, record)
prev_record = gapfilled_records.find { |r| r.date == date - 1.day }
if prev_record
new_record = create_gapfilled_record(prev_record, date)
gapfilled_records << new_record
end
else
gapfilled_records << record if record
end
end
gapfilled_records
end
private
attr_reader :date_range, :cache
def should_gapfill?(date, record)
date.on_weekend? && record.nil?
end
def create_gapfilled_record(prev_record, date)
new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at"))
new_record.date = date
new_record.save! if cache
new_record
end
end

View File

@@ -1,186 +1,137 @@
class Import < ApplicationRecord
belongs_to :account
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
validate :raw_csv_must_be_parsable
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
before_save :initialize_csv, if: :should_initialize_csv?
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
store_accessor :column_mappings, :define_column_mapping_keys
belongs_to :family
scope :ordered, -> { order(created_at: :desc) }
FALLBACK_TRANSACTION_NAME = "Imported transaction"
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
validates :type, inclusion: { in: TYPES }
validates :col_sep, inclusion: { in: [ ",", ";" ] }
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
has_many :rows, dependent: :destroy
has_many :mappings, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
def publish_later
raise "Import is not publishable" unless publishable?
update! status: :importing
ImportJob.perform_later(self)
end
def loaded?
raw_csv_str.present?
end
def configured?
csv.present?
end
def cleaned?
loaded? && configured? && csv.valid?
end
def csv
get_normalized_csv_with_validation
end
def available_headers
get_raw_csv.table.headers
end
def get_selected_header_for_field(field)
column_mappings&.dig(field.key) || field.key
end
def update_csv!(row_idx:, col_idx:, value:)
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
update! normalized_csv_str: updated_csv.to_s
end
# Type-specific methods (potential STI inheritance in future when more import types added)
def publish
update!(status: "importing")
import!
transaction do
generate_transactions.each do |txn|
txn.save!
end
end
family.sync
self.account.sync
update! status: :complete
rescue => error
update! status: :failed, error: error.message
end
update!(status: "complete")
rescue => e
update!(status: "failed")
Rails.logger.error("Import with id #{id} failed: #{e}")
def csv_rows
@csv_rows ||= parsed_csv
end
def csv_headers
parsed_csv.headers
end
def csv_sample
@csv_sample ||= parsed_csv.first(2)
end
def dry_run
generate_transactions
{
transactions: rows.count,
accounts: Import::AccountMapping.for_import(self).creational.count,
categories: Import::CategoryMapping.for_import(self).creational.count,
tags: Import::TagMapping.for_import(self).creational.count
}
end
def expected_fields
@expected_fields ||= create_expected_fields
def required_column_keys
[]
end
def column_keys
raise NotImplementedError, "Subclass must implement column_keys"
end
def generate_rows_from_csv
rows.destroy_all
mapped_rows = csv_rows.map do |row|
{
account: row[account_col_label].to_s,
date: row[date_col_label].to_s,
qty: row[qty_col_label].to_s,
ticker: row[ticker_col_label].to_s,
price: row[price_col_label].to_s,
amount: row[amount_col_label].to_s,
currency: (row[currency_col_label] || default_currency).to_s,
name: (row[name_col_label] || default_row_name).to_s,
category: row[category_col_label].to_s,
tags: row[tags_col_label].to_s,
entity_type: row[entity_type_col_label].to_s,
notes: row[notes_col_label].to_s
}
end
rows.insert_all!(mapped_rows)
end
def sync_mappings
mapping_steps.each do |mapping|
mapping.sync(self)
end
end
def mapping_steps
[]
end
def uploaded?
raw_file_str.present?
end
def configured?
uploaded? && rows.any?
end
def cleaned?
configured? && rows.all?(&:valid?)
end
def publishable?
cleaned? && mappings.all?(&:valid?)
end
private
def get_normalized_csv_with_validation
return nil if normalized_csv_str.nil?
csv = Import::Csv.new(normalized_csv_str)
expected_fields.each do |field|
csv.define_validator(field.key, field.validator) if field.validator
end
csv
def import!
# no-op, subclasses can implement for customization of algorithm
end
def get_raw_csv
return nil if raw_csv_str.nil?
Import::Csv.new(raw_csv_str, col_sep:)
def default_row_name
"Imported item"
end
def should_initialize_csv?
raw_csv_str_changed? || column_mappings_changed?
def default_currency
family.currency
end
def initialize_csv
generated_csv = generate_normalized_csv(raw_csv_str)
self.normalized_csv_str = generated_csv.table.to_s
end
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
def generate_normalized_csv(csv_str)
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
end
def update_csv(row_idx, col_idx, value)
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
update! normalized_csv_str: updated_csv.to_s
end
def generate_transactions
transaction_entries = []
category_cache = {}
tag_cache = {}
csv.table.each do |row|
category_name = row["category"].presence
tag_strings = row["tags"].presence&.split("|") || []
tags = []
tag_strings.each do |tag_string|
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
end
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
entry = account.entries.build \
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
date: Date.iso8601(row["date"]),
currency: account.currency,
amount: BigDecimal(row["amount"]) * -1,
entryable: Account::Transaction.new(category: category, tags: tags)
transaction_entries << entry
end
transaction_entries
end
def create_expected_fields
date_field = Import::Field.new \
key: "date",
label: "Date",
validator: ->(value) { Import::Field.iso_date_validator(value) }
name_field = Import::Field.new \
key: "name",
label: "Name",
is_optional: true
category_field = Import::Field.new \
key: "category",
label: "Category",
is_optional: true
tags_field = Import::Field.new \
key: "tags",
label: "Tags",
is_optional: true
amount_field = Import::Field.new \
key: "amount",
label: "Amount",
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
[ date_field, name_field, category_field, tags_field, amount_field ]
end
def define_column_mapping_keys
expected_fields.each do |field|
field.key.to_sym
end
end
def raw_csv_must_be_parsable
begin
CSV.parse(raw_csv_str || "", col_sep:)
rescue CSV::MalformedCSVError
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_csv_str.invalid_csv_format')
errors.add(:raw_csv_str, :invalid_csv_format)
end
def parsed_csv
@parsed_csv ||= CSV.parse(
(raw_file_str || "").strip,
headers: true,
col_sep: col_sep,
converters: [ ->(str) { str&.strip } ]
)
end
end

View File

@@ -0,0 +1,45 @@
class Import::AccountMapping < Import::Mapping
validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty }
class << self
def mapping_values(import)
import.rows.map(&:account).uniq
end
end
def selectable_values
family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] }
unless key.blank?
family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ]
end
family_accounts
end
def requires_selection?
true
end
def values_count
import.rows.where(account: key).count
end
def mappable_class
Account
end
def create_mappable!
return unless creatable?
account = import.family.accounts.create_or_find_by!(name: key) do |new_account|
new_account.balance = 0
new_account.import = import
new_account.currency = import.family.currency
new_account.accountable = Depository.new
end
self.mappable = account
save!
end
end

View File

@@ -0,0 +1,25 @@
class Import::AccountTypeMapping < Import::Mapping
validates :value, presence: true
class << self
def mapping_values(import)
import.rows.map(&:entity_type).uniq
end
end
def selectable_values
Accountable::TYPES.map { |type| [ type.titleize, type ] }
end
def requires_selection?
true
end
def values_count
import.rows.where(entity_type: key).count
end
def create_mappable!
# no-op
end
end

View File

@@ -0,0 +1,36 @@
class Import::CategoryMapping < Import::Mapping
class << self
def mapping_values(import)
import.rows.map(&:category).uniq
end
end
def selectable_values
family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }
unless key.blank?
family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ]
end
family_categories
end
def requires_selection?
false
end
def values_count
import.rows.where(category: key).count
end
def mappable_class
Category
end
def create_mappable!
return unless creatable?
self.mappable = import.family.categories.find_or_create_by!(name: key)
save!
end
end

View File

@@ -1,83 +0,0 @@
class Import::Csv
DEFAULT_COL_SEP = ",".freeze
COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze
def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP)
CSV.parse(
csv_str&.strip || "",
headers: true,
col_sep:,
converters: [ ->(str) { str&.strip } ]
)
end
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
raw_csv = self.parse_csv(raw_csv_str, col_sep:)
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
raw_csv.each do |row|
row_values = []
fields.each do |field|
# Finds the column header name the user has designated for the expected field
mapped_field_key = field_mappings[field.key] if field_mappings
mapped_header = mapped_field_key || field.key
row_values << row.fetch(mapped_header, "")
end
csv << row_values
end
end
new(generated_csv_str, col_sep:)
end
attr_reader :csv_str, :col_sep
def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP)
@csv_str = csv_str
@col_sep = col_sep
@column_validators = column_validators || {}
end
def table
@table ||= self.class.parse_csv(csv_str, col_sep:)
end
def update_cell(row_idx, col_idx, value)
copy = table.by_col_or_row
copy[row_idx][col_idx] = value
copy
end
def valid?
table.each_with_index.all? do |row, row_idx|
row.each_with_index.all? do |cell, col_idx|
cell_valid?(row_idx, col_idx)
end
end
end
def cell_valid?(row_idx, col_idx)
value = table.dig(row_idx, col_idx)
header = table.headers[col_idx]
validator = get_validator_by_header(header)
validator.call(value)
end
def define_validator(header_key, validator = nil, &block)
header = table.headers.find { |h| h.strip == header_key }
raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
column_validators[header] = validator || block
end
private
attr_accessor :column_validators
def get_validator_by_header(header)
column_validators&.dig(header) || ->(_v) { true }
end
end

View File

@@ -1,37 +0,0 @@
class Import::Field
def self.iso_date_validator(value)
Date.iso8601(value)
true
rescue
false
end
def self.bigdecimal_validator(value)
BigDecimal(value)
true
rescue
false
end
attr_reader :key, :label, :validator
def initialize(key:, label:, is_optional: false, validator: nil)
@key = key.to_s
@label = label
@is_optional = is_optional
@validator = validator
end
def optional?
@is_optional
end
def define_validator(validator = nil, &block)
@validator = validator || block
end
def validate(value)
return true if validator.nil?
validator.call(value)
end
end

View File

@@ -0,0 +1,56 @@
class Import::Mapping < ApplicationRecord
CREATE_NEW_KEY = "internal_new_resource"
belongs_to :import
belongs_to :mappable, polymorphic: true, optional: true
validates :key, presence: true, uniqueness: { scope: [ :import_id, :type ] }, allow_blank: true
scope :for_import, ->(import) { where(import: import) }
scope :creational, -> { where(create_when_empty: true, mappable: nil) }
scope :categories, -> { where(type: "Import::CategoryMapping") }
scope :tags, -> { where(type: "Import::TagMapping") }
scope :accounts, -> { where(type: "Import::AccountMapping") }
scope :account_types, -> { where(type: "Import::AccountTypeMapping") }
class << self
def mappable_for(key)
find_by(key: key)&.mappable
end
def sync(import)
unique_values = mapping_values(import).uniq
unique_values.each do |value|
mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?)
mapping.save(validate: false) if mapping.new_record?
end
where(import: import).where.not(key: unique_values).destroy_all
end
def mapping_values(import)
raise NotImplementedError, "Subclass must implement mapping_values"
end
end
def selectable_values
raise NotImplementedError, "Subclass must implement selectable_values"
end
def values_count
raise NotImplementedError, "Subclass must implement values_count"
end
def mappable_class
nil
end
def creatable?
mappable.nil? && key.present? && create_when_empty
end
def create_mappable!
raise NotImplementedError, "Subclass must implement create_mappable!"
end
end

76
app/models/import/row.rb Normal file
View File

@@ -0,0 +1,76 @@
class Import::Row < ApplicationRecord
belongs_to :import
validates :amount, numericality: true, allow_blank: true
validates :currency, presence: true
validate :date_matches_user_format
validate :required_columns
validate :currency_is_valid
scope :ordered, -> { order(:id) }
def tags_list
if tags.blank?
[ "" ]
else
tags.split("|").map(&:strip)
end
end
def date_iso
Date.strptime(date, import.date_format).iso8601
end
def signed_amount
if import.type == "TradeImport"
price.to_d * apply_trade_signage_convention(qty.to_d)
else
apply_transaction_signage_convention(amount.to_d)
end
end
def sync_mappings
Import::CategoryMapping.sync(import) if import.column_keys.include?(:category)
Import::TagMapping.sync(import) if import.column_keys.include?(:tags)
Import::AccountMapping.sync(import) if import.column_keys.include?(:account)
Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type)
end
private
# In the Maybe system, positive quantities == "inflows"
def apply_trade_signage_convention(value)
value * (import.signage_convention == "inflows_positive" ? 1 : -1)
end
# In the Maybe system, positive amounts == "outflows", so we must reverse signage
def apply_transaction_signage_convention(value)
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
end
def required_columns
import.required_column_keys.each do |required_key|
errors.add(required_key, "is required") if self[required_key].blank?
end
end
def date_matches_user_format
return if date.blank?
parsed_date = Date.strptime(date, import.date_format) rescue nil
if parsed_date.nil?
errors.add(:date, "must exactly match the format: #{import.date_format}")
end
end
def currency_is_valid
return true if currency.blank?
begin
Money::Currency.new(currency)
rescue Money::Currency::UnknownCurrencyError
errors.add(:currency, "is not a valid currency code")
end
end
end

View File

@@ -0,0 +1,36 @@
class Import::TagMapping < Import::Mapping
class << self
def mapping_values(import)
import.rows.map(&:tags_list).flatten.uniq
end
end
def selectable_values
family_tags = import.family.tags.alphabetically.map { |tag| [ tag.name, tag.id ] }
unless key.blank?
family_tags.unshift [ "Add as new tag", CREATE_NEW_KEY ]
end
family_tags
end
def requires_selection?
false
end
def values_count
import.rows.map(&:tags_list).flatten.count { |tag| tag == key }
end
def mappable_class
Tag
end
def create_mappable!
return unless creatable?
self.mappable = import.family.tags.find_or_create_by!(name: key)
save!
end
end

View File

@@ -4,4 +4,22 @@ class Institution < ApplicationRecord
has_one_attached :logo
scope :alphabetically, -> { order(name: :asc) }
def sync
accounts.active.each do |account|
if account.needs_sync?
account.sync
end
end
update! last_synced_at: Time.now
end
def syncing?
accounts.active.any? { |account| account.syncing? }
end
def has_issues?
accounts.active.any? { |account| account.has_issues? }
end
end

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