Compare commits

...

46 Commits

Author SHA1 Message Date
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
Zach Gollwitzer
e6528bafec Bump to v0.1.0-alpha.15
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-16 16:09:37 -04:00
Zach Gollwitzer
1b6ce6af45 Improved UI warning states for holdings with missing data (#1098)
* Fix security price issue flow

* Fix tooltip positioning and add tooltip for missing holding data

* Fix tooltip controller error with stale arrow target

* Lint fixes
2024-08-16 16:08:27 -04:00
Alexander Schrot
4527482aa2 Add support for different column separator in csv import logic (#1096)
* add col_sep to import model

* add validation for col_sep column

* add col_sep option to csv import model

* make use of col_sep option in import model

* add column separator field to new/edit action of an import

* add col_sep parameter to create/update action

* fix spacing between fields

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Alexander Schrot <alexander@axs-labs.com>

---------

Signed-off-by: Alexander Schrot <alexander@axs-labs.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-08-16 14:00:16 -04:00
Zach Gollwitzer
707c5ca0ca Account Issue Model and Resolution Flow + Troubleshooting guides (#1090)
* Rough draft of issue system

* Simplify design

* Remove stale files from merge conflicts

* STI for issues

* Cleanup

* Improve Synth api key flow

* Stub api key for test
2024-08-16 12:13:48 -04:00
Alexander Schrot
c70a08aca2 add pagination to account transactions list (#1095)
* add pagination to account transactions list

* use global pagination partial
2024-08-16 09:00:05 -04:00
Zach Gollwitzer
9dda2606d5 Bump Dockerfile to 3.3.4
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-15 13:23:40 -04:00
Zach Gollwitzer
acf3564a86 Fix for invalid accountable data (#1086) 2024-08-15 12:49:49 -04:00
Josh Pigford
1f6f55c4a8 Switch to general release of Rails 7.2 2024-08-15 11:17:28 -05:00
Zach Gollwitzer
0691041d37 Update required Ruby version for development in README
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-13 12:50:26 -04:00
Chris Covington
b437bb20c4 Bump ruby from 3.3.1 to 3.3.4 (#1084) 2024-08-13 12:49:51 -04:00
Pedro Carmona
3c64f3ff3b Fix: i18n symbol typo (#1085) 2024-08-13 12:31:51 -04:00
dependabot[bot]
82d3b8bcaf Bump rails from 43530b4 to f6d62b5 (#1083)
Bumps [rails](https://github.com/rails/rails) from `43530b4` to `f6d62b5`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](43530b4ac9...f6d62b5f21)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:33:49 -04:00
dependabot[bot]
67f65d399e Bump bootsnap from 1.18.3 to 1.18.4 (#1079)
Bumps [bootsnap](https://github.com/Shopify/bootsnap) from 1.18.3 to 1.18.4.
- [Changelog](https://github.com/Shopify/bootsnap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Shopify/bootsnap/compare/v1.18.3...v1.18.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:33:30 -04:00
dependabot[bot]
72fe6d87f0 Bump tailwindcss-rails from 2.6.5 to 2.7.2 (#1078)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.5 to 2.7.2.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.5...v2.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:26:38 -04:00
Zach Gollwitzer
94be117a02 Deposit, Withdrawal, and Interest Transactions for Investment View (#1075)
* Trade and Transaction builders

* Consolidate logic

* Remove redundant fields from trade form

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

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

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

* Scaffold out trade transaction form

* Normalize translations

* Add buy and sell trade form with tests

* Move entryable lists to dedicated controllers

* Delegate entry group contents rendering

* More cleanup

* Extract transaction and valuation update logic from entries controller

* Delegate edit and show actions to entryables

* Trade builder

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

* Remove empty line

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

* Handle paginated response

* Rename method and improve tests

* Change argument names

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

* Add test for tooltip

* Remove comma

* Normalize translations

* Use floating-ui instead popper

* Use component classes

* Increase cross axis value

* Cleanup

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

Use correct tailwind class

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

* Use default values for options

* Remove tooltip global variable

* Add arrow target

* Remove unused method

---------

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:13:03 -04:00
dependabot[bot]
cdb134077d Bump good_job from 4.1.0 to 4.1.1 (#1053)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.1.0...v4.1.1)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:59:30 -04:00
dependabot[bot]
e0d2b951d6 Bump erb_lint from 0.5.0 to 0.6.0 (#1057)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.5.0...v0.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:58:05 -04:00
dependabot[bot]
4eeca00121 Bump faraday from 2.10.0 to 2.10.1 (#1055)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.10.0 to 2.10.1.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.10.0...v2.10.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:57:19 -04:00
dependabot[bot]
07a7a6b1aa Bump tailwindcss-rails from 2.6.4 to 2.6.5 (#1058)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.4 to 2.6.5.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.4...v2.6.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:57:03 -04:00
248 changed files with 3854 additions and 1612 deletions

View File

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

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

View File

@@ -1,8 +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/IndentationStyle:
EnforcedStyle: spaces
IndentationWidth: 2
Layout/IndentationConsistency:
Enabled: true
Layout/SpaceInsidePercentLiteralDelimiters:
Enabled: true

View File

@@ -1 +1 @@
3.3.1
3.3.4

View File

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

View File

@@ -3,7 +3,7 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", github: "rails/rails", branch: "7-2-stable"
gem "rails", "~> 7.2.0"
# 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

@@ -5,71 +5,69 @@ GIT
lucide-rails (0.2.0)
railties (>= 4.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 5cb5cad3224d03114313fbe28c4dd1374c313d8f
branch: 7-2-stable
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actioncable (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionmailbox (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
mail (>= 2.8.0)
actionmailer (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
actionview (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionmailer (7.2.0)
actionpack (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activesupport (= 7.2.0)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0.beta3)
actionview (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionpack (7.2.0)
actionview (= 7.2.0)
activesupport (= 7.2.0)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actiontext (7.2.0)
actionpack (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionview (7.2.0)
activesupport (= 7.2.0)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activejob (7.2.0)
activesupport (= 7.2.0)
globalid (>= 0.3.6)
activemodel (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activerecord (7.2.0.beta3)
activemodel (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activemodel (7.2.0)
activesupport (= 7.2.0)
activerecord (7.2.0)
activemodel (= 7.2.0)
activesupport (= 7.2.0)
timeout (>= 0.4.0)
activestorage (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activestorage (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activesupport (= 7.2.0)
marcel (~> 1.0)
activesupport (7.2.0.beta3)
activesupport (7.2.0)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -78,51 +76,26 @@ GIT
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.beta3)
actioncable (= 7.2.0.beta3)
actionmailbox (= 7.2.0.beta3)
actionmailer (= 7.2.0.beta3)
actionpack (= 7.2.0.beta3)
actiontext (= 7.2.0.beta3)
actionview (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activemodel (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
bundler (>= 1.15.0)
railties (= 7.2.0.beta3)
railties (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.952.0)
aws-sdk-core (3.201.1)
aws-partitions (1.965.0)
aws-sdk-core (3.201.5)
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-sigv4 (~> 1.5)
aws-sdk-s3 (1.156.0)
aws-sdk-s3 (1.158.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
@@ -135,9 +108,9 @@ GEM
smart_properties
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.18.3)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.1.2)
brakeman (6.2.1)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -151,7 +124,7 @@ GEM
xpath (~> 3.2)
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -168,22 +141,22 @@ GEM
dotenv (= 3.1.2)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.5.0)
erb_lint (0.6.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
rainbow
rubocop
rubocop (>= 1)
smart_properties
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.10.0)
faraday (2.10.1)
faraday-net_http (>= 2.0, < 3.2)
logger
faraday-net_http (3.1.0)
faraday-net_http (3.1.1)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
@@ -193,12 +166,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.0)
good_job (4.2.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -261,7 +234,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)
@@ -292,14 +265,14 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.0.3)
parallel (1.24.0)
parser (3.3.1.0)
pagy (9.0.5)
parallel (1.25.1)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
pg (1.5.7)
prism (0.30.0)
propshaft (0.9.0)
propshaft (0.9.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@@ -319,6 +292,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)
bundler (>= 1.15.0)
railties (= 7.2.0)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -332,6 +319,14 @@ 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)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
@@ -345,15 +340,15 @@ GEM
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
rexml (3.3.2)
rexml (3.3.4)
strscan
rubocop (1.63.5)
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
@@ -376,13 +371,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.8)
ruby-lsp (0.17.13)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.11)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-lsp-rails (0.3.13)
ruby-lsp (>= 0.17.12, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -392,16 +387,17 @@ GEM
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.3.1)
selenium-webdriver (4.23.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.18.2)
sentry-rails (5.19.0)
railties (>= 5.0)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -411,23 +407,23 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11491)
sorbet-runtime (0.5.11528)
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.6.4)
tailwindcss-rails (2.7.3)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-aarch64-linux)
tailwindcss-rails (2.7.3-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-arm-linux)
tailwindcss-rails (2.7.3-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-arm64-darwin)
tailwindcss-rails (2.7.3-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-x86_64-darwin)
tailwindcss-rails (2.7.3-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-x86_64-linux)
tailwindcss-rails (2.7.3-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -497,7 +493,7 @@ DEPENDENCIES
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails!
rails (~> 7.2.0)
rails-settings-cached
redcarpet
rubocop-rails-omakase
@@ -516,7 +512,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.1p55
ruby 3.3.4p94
BUNDLED WITH
2.5.9

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

View File

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

View File

@@ -4,11 +4,11 @@
/* Reset rules, default styles applied to plain HTML */
@layer base {
details > summary::-webkit-details-marker {
details>summary::-webkit-details-marker {
@apply hidden;
}
details > summary {
details>summary {
@apply list-none;
}
}
@@ -38,7 +38,7 @@
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked + label + .toggle-switch-dot {
input:checked+label+.toggle-switch-dot {
transform: translateX(100%);
}
@@ -90,6 +90,10 @@
@apply font-bold;
}
}
.tooltip {
@apply hidden absolute;
}
}
/* Small, single purpose classes that should take precedence over other styles */

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -25,6 +25,8 @@ class AccountsController < ApplicationController
def new
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
@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])
end

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

@@ -13,27 +13,27 @@ module Authentication
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 user = User.find_by(id: session[:user_id])
Current.user = user
else
redirect_to new_session_url
end
end
end
def login(user)
Current.user = user
reset_session
session[:user_id] = user.id
set_last_login_at
end
def login(user)
Current.user = user
reset_session
session[:user_id] = user.id
set_last_login_at
end
def logout
Current.user = nil
reset_session
end
def logout
Current.user = nil
reset_session
end
def set_last_login_at
Current.user.update(last_login_at: DateTime.now)
end
def set_last_login_at
Current.user.update(last_login_at: DateTime.now)
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

@@ -1,7 +1,7 @@
require "ostruct"
class ImportsController < ApplicationController
before_action :set_import, except: %i[ index new create ]
before_action :set_import, except: %i[index new create]
def index
@imports = Current.family.imports
@@ -18,14 +18,14 @@ class ImportsController < ApplicationController
def update
account = Current.family.accounts.find(params[:import][:account_id])
@import.update! account: account, col_sep: params[:import][:col_sep]
@import.update! account: account
redirect_to load_import_path(@import), notice: t(".import_updated")
end
def create
account = Current.family.accounts.find(params[:import][:account_id])
@import = Import.create!(account: account)
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
redirect_to load_import_path(@import), notice: t(".import_created")
end
@@ -40,7 +40,7 @@ class ImportsController < ApplicationController
def upload_csv
begin
@import.raw_csv_str = import_params[:raw_csv_str].read
@import.raw_file_str = import_params[:raw_file_str].read
rescue NoMethodError
flash.now[:alert] = "Please select a file to upload"
render :load, status: :unprocessable_entity and return
@@ -113,6 +113,6 @@ class ImportsController < ApplicationController
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 ])
params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
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

View File

@@ -0,0 +1,19 @@
class Issue::ExchangeRateProviderMissingsController < ApplicationController
before_action :set_issue, only: :update
def update
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
@issue.issuable.sync_later
redirect_back_or_to account_path(@issue.issuable)
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
def exchange_rate_params
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
end
end

View File

@@ -0,0 +1,13 @@
class IssuesController < ApplicationController
before_action :set_issue, only: :show
def show
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
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

@@ -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

@@ -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

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

@@ -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

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

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: valuation_account_entries_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
# 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 ]

View File

@@ -4,6 +4,12 @@ module FormsHelper
form_with(**options, &block)
end
def modal_form_wrapper(title:, subtitle: nil, &block)
content = capture &block
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
end
def form_field_tag(options = {}, &block)
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
tag.div(**options, &block)
@@ -23,17 +29,17 @@ module FormsHelper
def money_with_currency_field(form, money_method, options = {})
render partial: "shared/money_field", locals: {
form: form,
money_method: money_method,
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"
label: options[:label] || "Amount"
}
end
def money_field(form, method, options = {})
value = form.object.send(method)
value = form.object ? form.object.send(method) : nil
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
@@ -42,10 +48,10 @@ module FormsHelper
money_options = {
value: value&.amount,
placeholder: 100,
min: -99999999999999,
max: 99999999999999,
step: currency.step
placeholder: "100",
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = options.merge(money_options)

View File

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

View File

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

View File

@@ -2,6 +2,11 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "preview", "submit", "filename", "filesize"]
static values = {
acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"]
acceptedExtension: String, // "csv"
unacceptableTypeLabel: String, // "Only CSV files are allowed."
};
connect() {
this.submitTarget.disabled = true
@@ -30,12 +35,12 @@ export default class extends Controller {
event.currentTarget.classList.remove("bg-gray-100")
const file = event.dataTransfer.files[0]
if (file && this._isCSVFile(file)) {
if (file && this._formatAcceptable(file)) {
this._setFileInput(file);
this._fileAdded(file)
} else {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = "Only CSV files are allowed."
this.previewTarget.textContent = this.unacceptableTypeLabelValue
}
}
@@ -57,7 +62,7 @@ export default class extends Controller {
if (file) {
if (file.size > fileSizeLimit) {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
this.previewTarget.textContent = this.unacceptableTypeLabelValue
return
}
@@ -80,10 +85,9 @@ export default class extends Controller {
}
}
_isCSVFile(file) {
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
_formatAcceptable(file) {
const extension = file.name.split('.').pop().toLowerCase()
return acceptedTypes.includes(file.type) || extension === "csv"
return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue
}
_setFileInput(file) {

View File

@@ -0,0 +1,83 @@
import { Controller } from '@hotwired/stimulus'
import {
computePosition,
flip,
shift,
offset,
autoUpdate
} from '@floating-ui/dom';
export default class extends Controller {
static targets = ["tooltip"];
static values = {
placement: { type: String, default: "top" },
offset: { type: Number, default: 10 },
crossAxis: { type: Number, default: 0 },
alignmentAxis: { type: Number, default: null },
};
connect() {
this._cleanup = null;
this.boundUpdate = this.update.bind(this);
this.startAutoUpdate();
this.addEventListeners();
}
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
}
addEventListeners() {
this.element.addEventListener("mouseenter", this.show);
this.element.addEventListener("mouseleave", this.hide);
}
removeEventListeners() {
this.element.removeEventListener("mouseenter", this.show);
this.element.removeEventListener("mouseleave", this.hide);
}
show = () => {
this.tooltipTarget.style.display = 'block';
this.update(); // Ensure immediate update when shown
}
hide = () => {
this.tooltipTarget.style.display = 'none';
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.element,
this.tooltipTarget,
this.boundUpdate
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
// Update position even if not visible, to ensure correct positioning when shown
computePosition(this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
flip(),
shift({ padding: 5 })
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltipTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
}

View File

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

View File

@@ -5,12 +5,12 @@ class ApplicationMailer < ActionMailer::Base
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
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,6 +1,5 @@
class Account < ApplicationRecord
include Syncable
include Monetizable
include Syncable, Monetizable, Issuable
validates :name, :balance, :currency, presence: true
@@ -15,6 +14,7 @@ class Account < ApplicationRecord
has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
monetize :balance
@@ -28,6 +28,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,33 +53,36 @@ 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
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
def owns_ticker?(ticker)
security_id = Security.find_by(ticker: ticker)&.id
entries.account_trades
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
.where(account_trades: { security_id: security_id }).any?
end
def favorable_direction

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

@@ -1,9 +1,6 @@
class Account::Balance::Syncer
attr_reader :warnings
def initialize(account, start_date: nil)
@account = account
@warnings = []
@sync_start_date = calculate_sync_start_date(start_date)
end
@@ -20,6 +17,8 @@ class Account::Balance::Syncer
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
end
end
rescue Money::ConversionError => e
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
end
private
@@ -67,20 +66,26 @@ class Account::Balance::Syncer
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
rescue Money::ConversionError
@warnings << "missing exchange rates from #{from_currency} to #{to_currency}"
[]
end
def build_balance(date, balance, currency = nil)

View File

@@ -204,7 +204,14 @@ class Account::Entry < ApplicationRecord
current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.ticker} because you only own #{current_qty} shares")
# 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

View File

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

View File

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

@@ -1,9 +1,6 @@
class Account::Holding::Syncer
attr_reader :warnings
def initialize(account, start_date: nil)
@account = account
@warnings = []
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
@portfolio = {}
@@ -69,6 +66,8 @@ class Account::Holding::Syncer
price = get_cached_price(ticker, date) || trade_price
account.observe_missing_price(ticker:, date:) unless price
account.holdings.build \
date: date,
security_id: holding[:security_id],

View File

@@ -16,34 +16,27 @@ class Account::Sync < ApplicationRecord
def run
start!
account.resolve_stale_issues
sync_balances
sync_holdings
complete!
rescue StandardError => error
account.observe_unknown_issue(error)
fail! error
raise error if Rails.env.development?
end
private
def sync_balances
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
syncer.run
append_warnings(syncer.warnings)
Account::Balance::Syncer.new(account, start_date: start_date).run
end
def sync_holdings
syncer = Account::Holding::Syncer.new(account, start_date: start_date)
syncer.run
append_warnings(syncer.warnings)
end
def append_warnings(new_warnings)
update! warnings: warnings + new_warnings
Account::Holding::Syncer.new(account, start_date: start_date).run
end
def start!
@@ -53,12 +46,17 @@ class Account::Sync < ApplicationRecord
def complete!
update! status: "completed"
broadcast_result type: "notice", message: "Sync complete"
if account.has_issues?
broadcast_result type: "alert", message: account.highest_priority_issue.title
else
broadcast_result type: "notice", message: "Sync complete"
end
end
def fail!(error)
update! status: "failed", error: error.message
broadcast_result type: "alert", message: error.message
broadcast_result type: "alert", message: I18n.t("account.sync.failed")
end
def broadcast_start
@@ -78,6 +76,7 @@ class Account::Sync < ApplicationRecord
partial: "shared/notification",
locals: { type: type, message: message }
)
account.family.broadcast_refresh
end
end

View File

@@ -1,10 +1,12 @@
class Account::Trade < ApplicationRecord
include Account::Entryable
include Account::Entryable, Monetizable
monetize :price
belongs_to :security
validates :qty, presence: true, numericality: { other_than: 0 }
validates :price, presence: true
validates :price, :currency, presence: true
class << self
def search(_params)

View File

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

View File

@@ -25,7 +25,7 @@ class Account::Transaction < ApplicationRecord
private
def searchable_keys
%i[ categories merchants ]
%i[categories merchants]
end
end

View File

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

View File

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

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

@@ -47,7 +47,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

@@ -0,0 +1,58 @@
module Issuable
extend ActiveSupport::Concern
included do
has_many :issues, dependent: :destroy, as: :issuable
end
def has_issues?
issues.active.any?
end
def resolve_stale_issues
issues.active.each do |issue|
issue.resolve! if issue.stale?
end
end
def observe_unknown_issue(error)
observe_issue(
Issue::Unknown.new(data: { error: error.message })
)
end
def observe_missing_exchange_rates(from:, to:, dates:)
observe_issue(
Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates })
)
end
def observe_missing_exchange_rate_provider
observe_issue(
Issue::ExchangeRateProviderMissing.new
)
end
def observe_missing_price(ticker:, date:)
issue = issues.find_or_create_by(type: Issue::PricesMissing.name, resolved_at: nil)
issue.append_missing_price(ticker, date)
issue.save!
end
def highest_priority_issue
issues.active.ordered.first
end
private
def observe_issue(new_issue)
existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil)
if existing_issue
existing_issue.update!(last_observed_at: Time.current, data: new_issue.data)
else
new_issue.issuable = self
new_issue.save!
end
end
end

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

@@ -21,10 +21,12 @@ module Providable
private
def synth_provider
@synth_provider ||= begin
api_key = ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
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
end
end

View File

@@ -207,7 +207,7 @@ class Demo::Generator
unknown = Security.find_by(ticker: "UNKNOWN")
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown)
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
trades = [
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
@@ -228,7 +228,7 @@ class Demo::Generator
amount: qty * price,
currency: "USD",
name: name_prefix + "#{qty} shares of #{security.ticker}",
entryable: Account::Trade.new(qty: qty, price: price, security: security)
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
end
end

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ class Family < ApplicationRecord
has_many :imports, through: :accounts
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
@@ -45,7 +46,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|

View File

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

View File

@@ -1,7 +1,8 @@
class Import < ApplicationRecord
belongs_to :account
validate :raw_csv_must_be_parsable
validate :raw_file_must_be_parsable
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
before_save :initialize_csv, if: :should_initialize_csv?
@@ -18,7 +19,7 @@ class Import < ApplicationRecord
end
def loaded?
raw_csv_str.present?
raw_file_str.present?
end
def configured?
@@ -87,22 +88,22 @@ class Import < ApplicationRecord
end
def get_raw_csv
return nil if raw_csv_str.nil?
Import::Csv.new(raw_csv_str)
return nil if raw_file_str.nil?
Import::Csv.new(raw_file_str, col_sep:)
end
def should_initialize_csv?
raw_csv_str_changed? || column_mappings_changed?
raw_file_str_changed? || column_mappings_changed?
end
def initialize_csv
generated_csv = generate_normalized_csv(raw_csv_str)
generated_csv = generate_normalized_csv(raw_file_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)
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
end
def update_csv(row_idx, col_idx, value)
@@ -174,11 +175,12 @@ class Import < ApplicationRecord
end
end
def raw_csv_must_be_parsable
def raw_file_must_be_parsable
begin
CSV.parse(raw_csv_str || "")
CSV.parse(raw_file_str || "", col_sep:)
rescue CSV::MalformedCSVError
errors.add(:raw_csv_str, "is not a valid CSV format")
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_file_str.invalid_csv_format')
errors.add(:raw_file_str, :invalid_csv_format)
end
end
end

View File

@@ -1,12 +1,20 @@
class Import::Csv
def self.parse_csv(csv_str)
CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str&.strip } ])
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)
raw_csv = self.parse_csv(raw_csv_str)
def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
raw_csv = self.parse_csv(raw_file_str, col_sep:)
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv|
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 = []
@@ -22,18 +30,19 @@ class Import::Csv
end
end
new(generated_csv_str)
new(generated_csv_str, col_sep:)
end
attr_reader :csv_str
attr_reader :csv_str, :col_sep
def initialize(csv_str, column_validators: nil)
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)
@table ||= self.class.parse_csv(csv_str, col_sep:)
end
def update_cell(row_idx, col_idx, value)

View File

@@ -16,10 +16,10 @@ class InviteCode < ApplicationRecord
private
def generate_token
loop do
self.token = SecureRandom.hex(4)
break token unless self.class.exists?(token: token)
def generate_token
loop do
self.token = SecureRandom.hex(4)
break token unless self.class.exists?(token: token)
end
end
end
end

35
app/models/issue.rb Normal file
View File

@@ -0,0 +1,35 @@
class Issue < ApplicationRecord
belongs_to :issuable, polymorphic: true
after_initialize :set_default_severity
enum :severity, { critical: 1, error: 2, warning: 3, info: 4 }
validates :severity, presence: true
scope :active, -> { where(resolved_at: nil) }
scope :ordered, -> { order(:severity) }
def title
model_name.human
end
# The conditions that must be met for an issue to be fixed
def stale?
raise NotImplementedError, "#{self.class} must implement #{__method__}"
end
def resolve!
update!(resolved_at: Time.current)
end
def default_severity
:warning
end
private
def set_default_severity
self.severity ||= default_severity
end
end

View File

@@ -0,0 +1,9 @@
class Issue::ExchangeRateProviderMissing < Issue
def default_severity
:error
end
def stale?
ExchangeRate.provider_healthy?
end
end

View File

@@ -0,0 +1,15 @@
class Issue::ExchangeRatesMissing < Issue
store_accessor :data, :from_currency, :to_currency, :dates
validates :from_currency, :to_currency, :dates, presence: true
def stale?
if dates.length == 1
ExchangeRate.find_rate(from: from_currency, to: to_currency, date: dates.first).present?
else
sorted_dates = dates.sort
rates = ExchangeRate.find_rates(from: from_currency, to: to_currency, start_date: sorted_dates.first, end_date: sorted_dates.last)
rates.length == dates.length
end
end
end

View File

@@ -0,0 +1,33 @@
class Issue::PricesMissing < Issue
store_accessor :data, :missing_prices
after_initialize :initialize_missing_prices
validates :missing_prices, presence: true
def append_missing_price(ticker, date)
missing_prices[ticker] ||= []
missing_prices[ticker] << date
end
def stale?
stale = true
missing_prices.each do |ticker, dates|
next unless issuable.owns_ticker?(ticker)
oldest_date = dates.min
expected_price_count = (oldest_date..Date.current).count
prices = Security::Price.find_prices(ticker: ticker, start_date: oldest_date)
stale = false if prices.count < expected_price_count
end
stale
end
private
def initialize_missing_prices
self.missing_prices ||= {}
end
end

View File

@@ -0,0 +1,11 @@
class Issue::Unknown < Issue
def default_severity
:warning
end
# Unknown issues are always stale because we only want to show them
# to the user once. If the same error occurs again, we'll create a new instance.
def stale?
true
end
end

20
app/models/measurement.rb Normal file
View File

@@ -0,0 +1,20 @@
class Measurement
include ActiveModel::Validations
attr_reader :value, :unit
VALID_UNITS = %w[sqft sqm mi km]
validates :unit, inclusion: { in: VALID_UNITS }
validates :value, presence: true
def initialize(value, unit)
@value = value.to_f
@unit = unit.to_s.downcase.strip
validate!
end
def to_s
"#{@value.to_i} #{@unit}"
end
end

View File

@@ -1,35 +1,35 @@
class Period
attr_reader :name, :date_range
attr_reader :name, :date_range
def self.find_by_name(name)
INDEX[name]
end
def self.names
INDEX.keys.sort
end
def initialize(name: "custom", date_range:)
@name = name
@date_range = date_range
end
def extend_backward(duration)
Period.new(name: name + "_extended", date_range: (date_range.first - duration)..date_range.last)
end
BUILTIN = [
new(name: "all", date_range: nil..Date.current),
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
]
INDEX = BUILTIN.index_by(&:name)
BUILTIN.each do |period|
define_singleton_method(period.name) do
period
end
def self.find_by_name(name)
INDEX[name]
end
def self.names
INDEX.keys.sort
end
def initialize(name: "custom", date_range:)
@name = name
@date_range = date_range
end
def extend_backward(duration)
Period.new(name: name + "_extended", date_range: (date_range.first - duration)..date_range.last)
end
BUILTIN = [
new(name: "all", date_range: nil..Date.current),
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
]
INDEX = BUILTIN.index_by(&:name)
BUILTIN.each do |period|
define_singleton_method(period.name) do
period
end
end
end

View File

@@ -1,3 +1,26 @@
class Property < ApplicationRecord
include Accountable
has_one :address, as: :addressable, dependent: :destroy
accepts_nested_attributes_for :address
attribute :area_unit, :string, default: "sqft"
def area
Measurement.new(area_value, area_unit) if area_value.present?
end
def purchase_price
first_valuation_amount
end
def trend
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
private
def first_valuation_amount
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
end
end

View File

@@ -16,11 +16,11 @@ class Provider::Github
latest_commit = Octokit.branch(repo, branch)
release_info = if latest_release
{
version: latest_version,
url: latest_release.html_url,
commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
}
{
version: latest_version,
url: latest_release.html_url,
commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
}
end
commit_info = {

View File

@@ -5,6 +5,11 @@ class Provider::Synth
@api_key = api_key
end
def healthy?
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
def fetch_security_prices(ticker:, start_date:, end_date:)
prices = paginate(
"#{base_url}/tickers/#{ticker}/open-close",
@@ -33,8 +38,7 @@ class Provider::Synth
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
response = client.get("#{base_url}/rates/historical") do |req|
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
@@ -58,17 +62,61 @@ class Provider::Synth
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
exchange_rates = paginate(
"#{base_url}/rates/historical-range",
from: from,
to: to,
date_start: start_date.to_s,
date_end: end_date.to_s
) do |body|
body.dig("data").map do |exchange_rate|
{
date: exchange_rate.dig("date"),
rate: exchange_rate.dig("rates", to)
}
end
end
ExchangeRatesResponse.new \
rates: exchange_rates,
success?: true,
raw_response: exchange_rates.to_json
rescue StandardError => error
ExchangeRatesResponse.new \
success?: false,
error: error,
raw_response: error
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"
end
def app_name
"maybe_app"
end
def app_type
Rails.application.config.app_mode
end
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.headers["Authorization"] = "Bearer #{api_key}"
faraday.headers["X-Source"] = app_name
faraday.headers["X-Source-Type"] = app_type
end
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch data from #{self.class}
@@ -78,7 +126,7 @@ class Provider::Synth
end
def fetch_page(url, page, params = {})
Faraday.get(url) do |req|
client.get(url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["page"] = page

View File

@@ -20,6 +20,8 @@ class Setting < RailsSettings::Base
field :app_domain, type: :string, default: ENV["APP_DOMAIN"]
field :email_sender, type: :string, default: ENV["EMAIL_SENDER"]
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
scope :smtp_settings do
field :smtp_host, type: :string, read_only: true, default: ENV["SMTP_ADDRESS"]
field :smtp_port, type: :string, read_only: true, default: ENV["SMTP_PORT"]

View File

@@ -1,5 +1,5 @@
class TimeSeries
DIRECTIONS = %w[ up down ].freeze
DIRECTIONS = %w[up down].freeze
attr_reader :values, :favorable_direction

View File

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

View File

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

View File

@@ -55,7 +55,8 @@ class User < ApplicationRecord
def can_deactivate
if admin? && family.users.count > 1
errors.add(:base, I18n.t("activerecord.errors.user.cannot_deactivate_admin_with_other_users"))
# i18n-tasks-use t('activerecord.errors.models.user.attributes.base.cannot_deactivate_admin_with_other_users')
errors.add(:base, :cannot_deactivate_admin_with_other_users)
end
end
@@ -73,17 +74,18 @@ class User < ApplicationRecord
private
def last_user_in_family?
family.users.count == 1
end
def deactivated_email
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
def profile_image_size
if profile_image.attached? && profile_image.byte_size > 5.megabytes
errors.add(:profile_image, "is too large. Maximum size is 5 MB.")
def last_user_in_family?
family.users.count == 1
end
def deactivated_email
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
def profile_image_size
if profile_image.attached? && profile_image.byte_size > 5.megabytes
# i18n-tasks-use t('activerecord.errors.models.user.attributes.profile_image.invalid_file_size')
errors.add(:profile_image, :invalid_file_size, max_megabytes: 5)
end
end
end
end

View File

@@ -3,100 +3,100 @@
attr_reader :name, :children, :value, :currency
def initialize(name, currency = Money.default_currency)
@name = name
@currency = Money::Currency.new(currency)
@children = []
@name = name
@currency = Money::Currency.new(currency)
@children = []
end
def sum
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
children.sum(&:sum)
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
children.sum(&:sum)
end
def avg
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
leaf_values = value_nodes.map(&:value)
leaf_values.compact.sum / leaf_values.compact.size
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
leaf_values = value_nodes.map(&:value)
leaf_values.compact.sum / leaf_values.compact.size
end
def series
return @series if is_value_node?
return @series if is_value_node?
summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
child.series.values.each do |series_value|
acc[series_value.date] += series_value.value
end
summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
child.series.values.each do |series_value|
acc[series_value.date] += series_value.value
end
end
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
TimeSeries.new(summed_series)
TimeSeries.new(summed_series)
end
def series=(series)
raise "Cannot set series on a non-leaf node" unless is_value_node?
raise "Cannot set series on a non-leaf node" unless is_value_node?
_series = series || TimeSeries.new([])
_series = series || TimeSeries.new([])
raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
@series = _series
raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
@series = _series
end
def value_nodes
return [ self ] unless value.nil?
children.flat_map { |child| child.value_nodes }
return [ self ] unless value.nil?
children.flat_map { |child| child.value_nodes }
end
def empty?
value_nodes.empty?
value_nodes.empty?
end
def percent_of_total
return 100 if parent.nil? || parent.sum.zero?
return 100 if parent.nil? || parent.sum.zero?
((sum / parent.sum) * 100).round(1)
((sum / parent.sum) * 100).round(1)
end
def add_child_group(name, currency = Money.default_currency)
raise "Cannot add subgroup to node with a value" if is_value_node?
child = self.class.new(name, currency)
child.parent = self
@children << child
child
raise "Cannot add subgroup to node with a value" if is_value_node?
child = self.class.new(name, currency)
child.parent = self
@children << child
child
end
def add_value_node(original, value, series = nil)
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
child = self.class.new(original.name)
child.original = original
child.value = value
child.series = series
child.parent = self
@children << child
child
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
child = self.class.new(original.name)
child.original = original
child.value = value
child.series = series
child.parent = self
@children << child
child
end
def value=(value)
raise "Cannot set value on a non-leaf node" unless is_leaf_node?
raise "Value must be an instance of Money" unless value.is_a?(Money)
@value = value
@currency = value.currency
raise "Cannot set value on a non-leaf node" unless is_leaf_node?
raise "Value must be an instance of Money" unless value.is_a?(Money)
@value = value
@currency = value.currency
end
def is_leaf_node?
children.empty?
children.empty?
end
def is_value_node?
value.present?
value.present?
end
private
def can_add_value_node?
return false if is_value_node?
children.empty? || children.all?(&:is_value_node?)
end
def can_add_value_node?
return false if is_value_node?
children.empty? || children.all?(&:is_value_node?)
end
end

View File

@@ -1,3 +1,22 @@
class Vehicle < ApplicationRecord
include Accountable
attribute :mileage_unit, :string, default: "mi"
def mileage
Measurement.new(mileage_value, mileage_unit) if mileage_value.present?
end
def purchase_price
first_valuation_amount
end
def trend
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
private
def first_valuation_amount
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,15 @@
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<div class="space-y-0.5">
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
<% if holding.amount %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
<% else %>
<%= render "missing_price_tooltip" %>
<% end %>
</div>
</div>
@@ -15,7 +21,7 @@
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<%= tag.p "--", class: "text-gray-500 mb-5" %>
<% end %>
</div>
@@ -28,7 +34,7 @@
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<%= tag.p "--", class: "text-gray-500" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
</div>
@@ -38,7 +44,7 @@
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<%= tag.p "--", class: "text-gray-500 mb-4" %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,11 @@
<div data-controller="tooltip" data-tooltip-cross-axis-value="50">
<div class="flex items-center gap-1 text-warning">
<%= lucide_icon "info", class: "w-4 h-4 shrink-0" %>
<%= tag.span t(".missing_data"), class: "font-normal text-xs" %>
</div>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
<div class="text-white">
<%= t(".description") %>
</div>
</div>
</div>

View File

@@ -2,10 +2,10 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_holding_path(@account),
disabled: true,
<%= link_to new_account_trade_path(@account),
id: dom_id(@account, "new_trade"),
data: { turbo_frame: :modal },
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_holding"), class: "text-sm" %>
<% end %>

View File

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

View File

@@ -13,14 +13,14 @@
<div class="max-w-full">
<%= tag.div class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry.name[0].upcase %>
<%= entry_name(entry).first.upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<%= content_tag :p, entry_name(entry) %>
<% else %>
<%= link_to entry.name,
<%= link_to entry_name(entry),
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
@@ -31,11 +31,20 @@
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
<% if entry.account_transaction? && entry.marked_as_transfer? %>
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
<% elsif entry.account_transaction? %>
<%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %>
<% else %>
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
<% end %>
</div>
<div class="col-span-3 flex items-center justify-end">
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% if entry.account_transaction? %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %>
<% else %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% end %>
</div>
</div>

View File

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

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