Compare commits
117 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6528bafec | ||
|
|
1b6ce6af45 | ||
|
|
4527482aa2 | ||
|
|
707c5ca0ca | ||
|
|
c70a08aca2 | ||
|
|
9dda2606d5 | ||
|
|
acf3564a86 | ||
|
|
1f6f55c4a8 | ||
|
|
0691041d37 | ||
|
|
b437bb20c4 | ||
|
|
3c64f3ff3b | ||
|
|
82d3b8bcaf | ||
|
|
14c4b9e93c | ||
|
|
150fce41a8 | ||
|
|
67f65d399e | ||
|
|
72fe6d87f0 | ||
|
|
94be117a02 | ||
|
|
f3c44464be | ||
|
|
c0908f454a | ||
|
|
e05f03b314 | ||
|
|
6bca35fa22 | ||
|
|
6fa40e0fa2 | ||
|
|
f315370512 | ||
|
|
6e74414cb2 | ||
|
|
9ad04a82cb | ||
|
|
7c878697f4 | ||
|
|
cdb134077d | ||
|
|
65aeab4681 | ||
|
|
e0d2b951d6 | ||
|
|
4eeca00121 | ||
|
|
07a7a6b1aa | ||
|
|
edda5cb35b | ||
|
|
ea8309eedd | ||
|
|
453a54e5e6 | ||
|
|
c70c8b6d86 | ||
|
|
f2a2d2f7e4 | ||
|
|
0a21c92643 | ||
|
|
2c5f647f53 | ||
|
|
11f58537db | ||
|
|
6231814e1e | ||
|
|
7645a9ec56 | ||
|
|
08b59ad5fe | ||
|
|
02adba5280 | ||
|
|
1f5721a8b1 | ||
|
|
7ba9830db5 | ||
|
|
dfc7e1c30c | ||
|
|
76dd5e57fb | ||
|
|
701e17829d | ||
|
|
7c2091b343 | ||
|
|
ef4be7948a | ||
|
|
c8590d53ba | ||
|
|
f62c5e43c3 | ||
|
|
82568b4d8c | ||
|
|
9d006409c2 | ||
|
|
55a085f01f | ||
|
|
23dcdf6e26 | ||
|
|
05e3e689b5 | ||
|
|
01f50dc54c | ||
|
|
5d213f2e6a | ||
|
|
952d847c15 | ||
|
|
e7dc6b88ea | ||
|
|
75ded1c18f | ||
|
|
c0e0c2bf62 | ||
|
|
fa08f027c7 | ||
|
|
b200b71284 | ||
|
|
ef0f910b9b | ||
|
|
e9f42c1a65 | ||
|
|
e51806b98b | ||
|
|
47523f64c2 | ||
|
|
d0bc959bee | ||
|
|
cdbca5aff3 | ||
|
|
41f9e23f8c | ||
|
|
12123449b7 | ||
|
|
a70c6666dc | ||
|
|
1bd5397701 | ||
|
|
37d5c149ba | ||
|
|
744ffb68aa | ||
|
|
34e03c2d6a | ||
|
|
b002a41b35 | ||
|
|
c6bdf49f10 | ||
|
|
de5a2e55b3 | ||
|
|
538b00712c | ||
|
|
2e56f5726e | ||
|
|
3c9cdb16f9 | ||
|
|
6d4c871f85 | ||
|
|
dd915c42ed | ||
|
|
0447d47a53 | ||
|
|
42dec4014e | ||
|
|
6767aaed1d | ||
|
|
bef335c631 | ||
|
|
3ffb6cb62b | ||
|
|
cea90252c8 | ||
|
|
36cccefb2a | ||
|
|
cc6bf6e961 | ||
|
|
48092cb704 | ||
|
|
cf23453d93 | ||
|
|
f1d0a62ac7 | ||
|
|
3089e3c81d | ||
|
|
0593d8fb7e | ||
|
|
a8ea207d47 | ||
|
|
c225eb6d03 | ||
|
|
9b148316bc | ||
|
|
8e7fcfd0b4 | ||
|
|
c3314e62d1 | ||
|
|
320954282a | ||
|
|
9e1d8a753b | ||
|
|
3d4def59d6 | ||
|
|
da18c3d850 | ||
|
|
cb3fd34f90 | ||
|
|
593892bc2b | ||
|
|
bbcd3881db | ||
|
|
ee53546c1b | ||
|
|
66c27b8df4 | ||
|
|
03e027e089 | ||
|
|
b7799aaa8e | ||
|
|
094128fef1 | ||
|
|
a5212f0f5e |
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.3.1
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
|
||||
17
.env.test.example
Normal file
17
.env.test.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
# Uncomment and fill in live keys when you need to generate a VCR cassette fixture
|
||||
# ================
|
||||
|
||||
# SYNTH_API_KEY=<add live key here>
|
||||
|
||||
# ================
|
||||
# Miscellaneous
|
||||
# ================
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -97,7 +97,6 @@ jobs:
|
||||
|
||||
- name: System tests
|
||||
run: DISABLE_PARALLELIZATION=true bin/rails test:system
|
||||
continue-on-error: true # TODO: Eventually we'll enforce for PRs
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
@@ -51,6 +52,9 @@
|
||||
# Ignore .devcontainer files
|
||||
compose-dev.yaml
|
||||
|
||||
# Ignore asdf ruby version file
|
||||
.tool-versions
|
||||
|
||||
# Ignore GCP keyfile
|
||||
gcp-storage-keyfile.json
|
||||
|
||||
|
||||
@@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
||||
# Layout/SpaceInsideArrayLiteralBrackets:
|
||||
# Enabled: false
|
||||
Layout/ElseAlignment:
|
||||
Enabled: false
|
||||
Layout/EndAlignment:
|
||||
Enabled: false
|
||||
@@ -1 +1 @@
|
||||
3.3.1
|
||||
3.3.4
|
||||
|
||||
@@ -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
|
||||
|
||||
6
Gemfile
6
Gemfile
@@ -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"
|
||||
@@ -44,6 +44,7 @@ gem "pagy"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "csv"
|
||||
gem "redcarpet"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[ mri windows ]
|
||||
@@ -51,14 +52,15 @@ group :development, :test do
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "i18n-tasks"
|
||||
gem "erb_lint"
|
||||
gem "dotenv-rails"
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem "dotenv-rails"
|
||||
gem "hotwire-livereload"
|
||||
gem "letter_opener"
|
||||
gem "ruby-lsp-rails"
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
284
Gemfile.lock
284
Gemfile.lock
@@ -5,124 +5,97 @@ GIT
|
||||
lucide-rails (0.2.0)
|
||||
railties (>= 4.1.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 5d34172ff44ec0c88ac03a979679b31e1ed78745
|
||||
branch: 7-2-stable
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actioncable (7.2.0)
|
||||
actionpack (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionmailbox (7.2.0)
|
||||
actionpack (= 7.2.0)
|
||||
activejob (= 7.2.0)
|
||||
activerecord (= 7.2.0)
|
||||
activestorage (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionmailer (7.2.0)
|
||||
actionpack (= 7.2.0)
|
||||
actionview (= 7.2.0)
|
||||
activejob (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionpack (7.2.0)
|
||||
actionview (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actiontext (7.2.0)
|
||||
actionpack (= 7.2.0)
|
||||
activerecord (= 7.2.0)
|
||||
activestorage (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionview (7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activejob (7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activerecord (7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activemodel (7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
activerecord (7.2.0)
|
||||
activemodel (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activestorage (7.2.0)
|
||||
actionpack (= 7.2.0)
|
||||
activejob (= 7.2.0)
|
||||
activerecord (= 7.2.0)
|
||||
activesupport (= 7.2.0)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.0.beta2)
|
||||
activesupport (7.2.0)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.beta2)
|
||||
actioncable (= 7.2.0.beta2)
|
||||
actionmailbox (= 7.2.0.beta2)
|
||||
actionmailer (= 7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actiontext (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.0.beta2)
|
||||
railties (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
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.944.0)
|
||||
aws-sdk-core (3.197.0)
|
||||
aws-partitions (1.961.0)
|
||||
aws-sdk-core (3.201.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.84.0)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.152.3)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.157.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
@@ -135,7 +108,7 @@ GEM
|
||||
smart_properties
|
||||
bigdecimal (3.1.8)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.3)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
racc
|
||||
@@ -151,7 +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,35 +141,43 @@ 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
|
||||
faraday (2.9.1)
|
||||
faker (3.4.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.10.1)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-net_http (3.1.0)
|
||||
logger
|
||||
faraday-net_http (3.1.1)
|
||||
net-http
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.16.3)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
ffi (1.17.0-arm-linux-gnu)
|
||||
ffi (1.17.0-arm64-darwin)
|
||||
ffi (1.17.0-x86-linux-gnu)
|
||||
ffi (1.17.0-x86_64-darwin)
|
||||
ffi (1.17.0-x86_64-linux-gnu)
|
||||
fugit (1.11.0)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (3.29.3)
|
||||
activejob (>= 6.0.0)
|
||||
activerecord (>= 6.0.0)
|
||||
concurrent-ruby (>= 1.0.2)
|
||||
fugit (>= 1.1)
|
||||
railties (>= 6.0.0)
|
||||
thor (>= 0.14.1)
|
||||
good_job (4.1.1)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (>= 1.11.0)
|
||||
railties (>= 6.1.0)
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.0)
|
||||
highline (3.0.1)
|
||||
hotwire-livereload (1.4.0)
|
||||
@@ -215,7 +196,7 @@ GEM
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.12.2)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.1)
|
||||
@@ -226,7 +207,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.13.2)
|
||||
irb (1.14.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@@ -251,15 +232,15 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.23.1)
|
||||
mocha (2.4.0)
|
||||
minitest (5.24.1)
|
||||
mocha (2.4.5)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.13)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -269,28 +250,28 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (8.4.4)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.1.0)
|
||||
pagy (9.0.5)
|
||||
parallel (1.25.1)
|
||||
parser (3.3.4.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.6)
|
||||
prism (0.29.0)
|
||||
pg (1.5.7)
|
||||
prism (0.30.0)
|
||||
propshaft (0.9.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -302,8 +283,8 @@ GEM
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
rack (3.1.3)
|
||||
racc (1.8.1)
|
||||
rack (3.1.7)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
@@ -311,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
|
||||
@@ -324,25 +319,36 @@ 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)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.5.2)
|
||||
logger
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rubocop (1.63.5)
|
||||
rexml (3.3.4)
|
||||
strscan
|
||||
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)
|
||||
@@ -365,29 +371,33 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.17.1)
|
||||
ruby-lsp (0.17.12)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.29.0, < 0.30)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.7)
|
||||
ruby-lsp (>= 0.17.0, < 0.18.0)
|
||||
ruby-lsp-rails (0.3.12)
|
||||
ruby-lsp (>= 0.17.12, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.1)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.21.1)
|
||||
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.17.3)
|
||||
sentry-rails (5.18.2)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.17.3)
|
||||
sentry-ruby (5.17.3)
|
||||
sentry-ruby (~> 5.18.2)
|
||||
sentry-ruby (5.18.2)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -397,29 +407,29 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11406)
|
||||
sorbet-runtime (0.5.11518)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.6.1)
|
||||
tailwindcss-rails (2.7.2)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
tailwindcss-rails (2.7.2-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
tailwindcss-rails (2.7.2-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
tailwindcss-rails (2.7.2-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
tailwindcss-rails (2.7.2-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
tailwindcss-rails (2.7.2-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.1)
|
||||
timeout (0.4.1)
|
||||
turbo-rails (2.0.5)
|
||||
turbo-rails (2.0.6)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
@@ -439,13 +449,13 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.1)
|
||||
websocket (1.2.10)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.16)
|
||||
zeitwerk (2.6.17)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -466,6 +476,7 @@ DEPENDENCIES
|
||||
debug
|
||||
dotenv-rails
|
||||
erb_lint
|
||||
faker
|
||||
faraday
|
||||
faraday-retry
|
||||
good_job
|
||||
@@ -482,8 +493,9 @@ DEPENDENCIES
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails!
|
||||
rails (~> 7.2.0)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
selenium-webdriver
|
||||
@@ -500,7 +512,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.1p55
|
||||
ruby 3.3.4p94
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.9
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,27 +4,27 @@
|
||||
|
||||
/* Reset rules, default styles applied to plain HTML */
|
||||
@layer base {
|
||||
details > summary::-webkit-details-marker {
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.form-field {
|
||||
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
|
||||
@apply block text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:opacity-50;
|
||||
@@ -38,7 +38,7 @@
|
||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked + label + .toggle-switch-dot {
|
||||
input:checked+label+.toggle-switch-dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply p-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply bg-gray-50;
|
||||
@apply after:content-['\2713'] after:float-right after:text-gray-500;
|
||||
}
|
||||
|
||||
.maybe-switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@@ -77,6 +90,10 @@
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
|
||||
14
app/controllers/account/cashes_controller.rb
Normal file
14
app/controllers/account/cashes_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
48
app/controllers/account/entries_controller.rb
Normal file
48
app/controllers/account/entries_controller.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
render entryable_view_path(:show)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_url(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
end
|
||||
23
app/controllers/account/holdings_controller.rb
Normal file
23
app/controllers/account/holdings_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_holding, only: :show
|
||||
|
||||
def index
|
||||
@holdings = @account.holdings.current
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_holding
|
||||
@holding = @account.holdings.current.find(params[:id])
|
||||
end
|
||||
end
|
||||
37
app/controllers/account/trades_controller.rb
Normal file
37
app/controllers/account/trades_controller.rb
Normal 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
|
||||
56
app/controllers/account/transactions_controller.rb
Normal file
56
app/controllers/account/transactions_controller.rb
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
|
||||
@@ -18,11 +18,12 @@ class Account::TransfersController < ApplicationController
|
||||
name: transfer_params[:name]
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @transfer.errors.full_messages.to_sentence
|
||||
flash[:alert] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,48 +1,26 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_valuation, only: %i[ show edit update destroy ]
|
||||
|
||||
def new
|
||||
@valuation = @account.valuations.new
|
||||
end
|
||||
|
||||
def show
|
||||
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def create
|
||||
@valuation = @account.valuations.build(valuation_params)
|
||||
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
|
||||
|
||||
if @valuation.save
|
||||
@valuation.sync_account_later
|
||||
redirect_to account_path(@account), notice: "Valuation created"
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_to account_valuations_path(@account), notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @valuation.errors.full_messages.to_sentence
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @valuation.update(valuation_params)
|
||||
@valuation.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @valuation.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@valuation.destroy!
|
||||
@valuation.sync_account_later
|
||||
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
def index
|
||||
@entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
private
|
||||
@@ -51,11 +29,7 @@ class Account::ValuationsController < ApplicationController
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_valuation
|
||||
@valuation = @account.valuations.find(params[:id])
|
||||
end
|
||||
|
||||
def valuation_params
|
||||
params.require(:account_valuation).permit(:date, :value, :currency)
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ edit show destroy sync update ]
|
||||
after_action :sync_account, only: :create
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@@ -20,13 +19,11 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def list
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Account.new(
|
||||
balance: nil,
|
||||
accountable: Accountable.from_type(params[:type])&.new
|
||||
)
|
||||
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
@@ -34,14 +31,19 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
@balance_series = @account.series(period: @period)
|
||||
@series = @account.series(period: @period)
|
||||
@trend = @series.trend
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update! account_params.except(:accountable_type)
|
||||
Account.transaction do
|
||||
@account.update! account_params.except(:accountable_type, :balance)
|
||||
@account.update_balance!(account_params[:balance]) if account_params[:balance]
|
||||
end
|
||||
@account.sync_later
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
@@ -52,8 +54,10 @@ class AccountsController < ApplicationController
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
@account.sync_later
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_back_or_to accounts_path, alert: e.record.errors.full_messages.to_sentence
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -65,8 +69,11 @@ class AccountsController < ApplicationController
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
def sync_all
|
||||
Current.family.accounts.active.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -78,8 +85,4 @@ class AccountsController < ApplicationController
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
|
||||
end
|
||||
|
||||
def sync_account
|
||||
@account.sync_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication, Invitable, SelfHostable
|
||||
include AutoSync, Authentication, Invitable, SelfHostable
|
||||
include Pagy::Backend
|
||||
|
||||
default_form_builder ApplicationFormBuilder
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
private
|
||||
|
||||
def with_sidebar
|
||||
return "turbo_rails/frame" if turbo_frame_request?
|
||||
|
||||
"with_sidebar"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
@@ -3,13 +3,11 @@ module Authentication
|
||||
|
||||
included do
|
||||
before_action :authenticate_user!
|
||||
after_action :set_last_login_at, if: -> { Current.user }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def skip_authentication(**options)
|
||||
skip_before_action :authenticate_user!, **options
|
||||
skip_after_action :set_last_login_at, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +25,7 @@ module Authentication
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
|
||||
def logout
|
||||
|
||||
13
app/controllers/concerns/auto_sync.rb
Normal file
13
app/controllers/concerns/auto_sync.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module AutoSync
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_family
|
||||
Current.family.sync
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class CurrenciesController < ApplicationController
|
||||
def show
|
||||
@currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
|
||||
render json: { step: @currency.step, placeholder: Money.new(0, @currency).format }
|
||||
currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
|
||||
render json: currency.as_json.merge({ step: currency.step })
|
||||
end
|
||||
end
|
||||
|
||||
11
app/controllers/help/articles_controller.rb
Normal file
11
app/controllers/help/articles_controller.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
unless @article
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -38,11 +38,26 @@ class ImportsController < ApplicationController
|
||||
def load
|
||||
end
|
||||
|
||||
def upload_csv
|
||||
begin
|
||||
@import.raw_csv_str = import_params[:raw_csv_str].read
|
||||
rescue NoMethodError
|
||||
flash.now[:alert] = "Please select a file to upload"
|
||||
render :load, status: :unprocessable_entity and return
|
||||
end
|
||||
if @import.save
|
||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||
else
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def load_csv
|
||||
if @import.update(import_params)
|
||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||
else
|
||||
flash.now[:error] = @import.errors.full_messages.to_sentence
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
13
app/controllers/issues_controller.rb
Normal file
13
app/controllers/issues_controller.rb
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class PagesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
|
||||
@@ -21,7 +21,7 @@ class PagesController < ApplicationController
|
||||
|
||||
@accounts = Current.family.accounts
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transactions = Current.family.transactions.limit(5).order(date: :desc)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
|
||||
@@ -19,7 +19,7 @@ class Settings::HostingsController < SettingsController
|
||||
|
||||
def send_test_email
|
||||
unless Setting.smtp_settings_populated?
|
||||
flash[:error] = t(".missing_smtp_setting_error")
|
||||
flash[:alert] = t(".missing_smtp_setting_error")
|
||||
render(:show, status: :unprocessable_entity)
|
||||
return
|
||||
end
|
||||
@@ -27,7 +27,7 @@ class Settings::HostingsController < SettingsController
|
||||
begin
|
||||
NotificationMailer.with(user: Current.user).test_email.deliver_now
|
||||
rescue => _e
|
||||
flash[:error] = t(".error")
|
||||
flash[:alert] = t(".error")
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class SettingsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TagsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
class Transaction::RowsController < ApplicationController
|
||||
before_action :set_transaction, only: %i[ show update ]
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
|
||||
redirect_to transaction_row_path(@transaction)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:category_id)
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@transaction = Current.family.transactions.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class Transaction::RulesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,10 @@
|
||||
class TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||
layout :with_sidebar
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
result = Current.family.transactions.search(@q).ordered
|
||||
@pagy, @transactions = pagy(result, items: params[:per_page] || "10")
|
||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
|
||||
|
||||
@totals = {
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
@@ -15,45 +13,27 @@ class TransactionsController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@transaction = Transaction.new.tap do |txn|
|
||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
||||
if params[:account_id]
|
||||
txn.account = Current.family.accounts.find(params[:account_id])
|
||||
e.account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def create
|
||||
@transaction = Current.family.accounts
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
@entry = Current.family
|
||||
.accounts
|
||||
.find(params[:account_entry][:account_id])
|
||||
.entries
|
||||
.create!(transaction_entry_params.merge(amount: amount))
|
||||
|
||||
@transaction.save!
|
||||
@transaction.sync_account_later
|
||||
redirect_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
@transaction.sync_account_later
|
||||
|
||||
redirect_to transaction_url(@transaction), notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transaction.destroy!
|
||||
@transaction.sync_account_later
|
||||
redirect_to transactions_url, notice: t(".success")
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_path(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
@@ -61,19 +41,18 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
|
||||
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count)
|
||||
else
|
||||
flash.now[:error] = t(".failure")
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.transactions
|
||||
.where(id: bulk_update_params[:transaction_ids])
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
@@ -81,44 +60,45 @@ class TransactionsController < ApplicationController
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.transactions
|
||||
.where(id: bulk_update_params[:transaction_ids])
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def rules
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@transaction = Current.family.transactions.find(params[:id])
|
||||
end
|
||||
private
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
transaction_params[:amount].to_d * -1
|
||||
transaction_entry_params[:amount].to_d * -1
|
||||
else
|
||||
transaction_params[:amount].to_d
|
||||
transaction_entry_params[:amount].to_d
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:transaction][:nature].to_s.inquiry
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(transaction_ids: [])
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :excluded, :category_id, :merchant_id, transaction_ids: [])
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [])
|
||||
def transaction_entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
13
app/helpers/account/cashes_helper.rb
Normal file
13
app/helpers/account/cashes_helper.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Account::CashesHelper
|
||||
def brokerage_cash(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.balance,
|
||||
price: 1,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
||||
61
app/helpers/account/entries_helper.rb
Normal file
61
app/helpers/account/entries_helper.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
module Account::EntriesHelper
|
||||
def permitted_entryable_partial_path(entry, relative_partial_path)
|
||||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(entry)
|
||||
entry.marked_as_transfer? && entry.transfer.nil?
|
||||
end
|
||||
|
||||
def transfer_entries(entries)
|
||||
transfers = entries.select { |e| e.transfer_id.present? }
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
|
||||
def entry_icon(entry, is_oldest: false)
|
||||
if is_oldest
|
||||
"keyboard"
|
||||
elsif entry.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif entry.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def entry_style(entry, is_oldest: false)
|
||||
color = is_oldest ? "#D444F1" : entry.trend.color
|
||||
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
def entry_name(entry)
|
||||
if entry.account_trade?
|
||||
trade = entry.account_trade
|
||||
prefix = trade.sell? ? "Sell " : "Buy "
|
||||
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
|
||||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name || "Transaction"
|
||||
end
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
|
||||
end.join.html_safe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
permitted_entryable_paths = %w[transaction valuation trade]
|
||||
entry.entryable_name_short.presence_in(permitted_entryable_paths)
|
||||
end
|
||||
end
|
||||
@@ -1,23 +0,0 @@
|
||||
module Account::ValuationsHelper
|
||||
def valuation_icon(valuation)
|
||||
if valuation.first_of_series?
|
||||
"keyboard"
|
||||
elsif valuation.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif valuation.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def valuation_style(valuation)
|
||||
color = valuation.first_of_series? ? "#D444F1" : valuation.trend.color
|
||||
|
||||
<<-STYLE.strip
|
||||
background-color: color-mix(in srgb, #{color} 5%, white);
|
||||
border-color: color-mix(in srgb, #{color} 10%, white);
|
||||
color: #{color};
|
||||
STYLE
|
||||
end
|
||||
end
|
||||
@@ -23,18 +23,38 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) }
|
||||
|
||||
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
end
|
||||
|
||||
def selected_account_tab(account)
|
||||
available_tabs = account_tabs(account)
|
||||
|
||||
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
|
||||
|
||||
tab || available_tabs.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
def initialize(object_name, object, template, options)
|
||||
options[:html] ||= {}
|
||||
options[:html][:class] ||= "space-y-4"
|
||||
|
||||
super(object_name, object, template, options)
|
||||
end
|
||||
|
||||
(field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(options)
|
||||
|
||||
return super(method, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
# See `Monetizable` concern, which adds a _money suffix to the attribute name
|
||||
# For a monetized field, the setter will always be the attribute name without the _money suffix
|
||||
def money_field(method, options = {})
|
||||
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
|
||||
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
|
||||
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
money_currency_method = :currency
|
||||
|
||||
readonly_currency = options[:readonly_currency] || false
|
||||
|
||||
currency = money&.currency || Money::Currency.new(Current.family.currency) || Money.default_currency
|
||||
default_options = {
|
||||
class: "form-field__input",
|
||||
value: money&.amount,
|
||||
"data-money-field-target" => "amount",
|
||||
placeholder: Money.new(0, currency).format,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = default_options.merge(options)
|
||||
|
||||
grouped_options = currency_options_for_select
|
||||
selected_currency = money&.currency&.iso_code || currency.iso_code
|
||||
|
||||
@template.form_field_tag data: { controller: "money-field" } do
|
||||
(label(method, *label_args(options)).to_s if options[:label]) +
|
||||
@template.tag.div(class: "flex items-center") do
|
||||
number_field(money_amount_method, merged_options.except(:label)) +
|
||||
grouped_select(money_currency_method, grouped_options, { selected: selected_currency, disabled: readonly_currency }, class: "ml-auto form-field__input w-fit pr-8", data: { "money-field-target" => "currency", action: "change->money-field#handleCurrencyChange" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
default_options = { class: "form-field__radio" }
|
||||
merged_options = default_options.merge(options)
|
||||
super(method, tag_value, merged_options)
|
||||
end
|
||||
|
||||
def grouped_select(method, grouped_choices, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_html_options = default_options.merge(html_options)
|
||||
|
||||
label_html = label(method, *label_args(options)).to_s if options[:label]
|
||||
select_html = @template.grouped_collection_select(@object_name, method, grouped_choices, :last, :first, :last, :first, options, merged_html_options)
|
||||
|
||||
@template.content_tag(:div, class: "flex items-center") do
|
||||
label_html.to_s.html_safe + select_html
|
||||
end
|
||||
end
|
||||
|
||||
def currency_select(method, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
choices = currency_options_for_select
|
||||
|
||||
return @template.grouped_collection_select(@object_name, method, choices, :last, :first, :last, :first, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
@template.grouped_collection_select(@object_name, method, choices, :last, :first, :last, :first, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
return super(method, choices, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, choices, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
return super(method, collection, value_method, text_method, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, collection, value_method, text_method, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
default_options = { class: "form-field__submit" }
|
||||
merged_options = default_options.merge(options)
|
||||
super(value, merged_options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def currency_options_for_select
|
||||
popular_currencies = Money::Currency.popular.map { |currency| [ currency.iso_code, currency.iso_code ] }
|
||||
all_currencies = Money::Currency.all_instances.map { |currency| [ currency.iso_code, currency.iso_code ] }
|
||||
all_other_currencies = all_currencies.reject { |c| popular_currencies.map(&:last).include?(c.last) }.sort_by(&:last)
|
||||
|
||||
{
|
||||
I18n.t("accounts.new.currency.popular") => popular_currencies,
|
||||
I18n.t("accounts.new.currency.all_others") => all_other_currencies
|
||||
}
|
||||
end
|
||||
|
||||
def label_args(options)
|
||||
case options[:label]
|
||||
when Array
|
||||
options[:label]
|
||||
when String
|
||||
[ options[:label], { class: "form-field__label" } ]
|
||||
when Hash
|
||||
[ nil, options[:label] ]
|
||||
else
|
||||
[ nil, { class: "form-field__label" } ]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,11 +13,22 @@ module ApplicationHelper
|
||||
name.underscore
|
||||
end
|
||||
|
||||
def notification(text, **options, &block)
|
||||
content = tag.p(text)
|
||||
content = capture &block if block_given?
|
||||
def family_notifications_stream
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
end
|
||||
|
||||
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
|
||||
def family_stream
|
||||
turbo_stream_from Current.family if Current.family
|
||||
end
|
||||
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map do |type, message_or_messages|
|
||||
Array(message_or_messages).map do |message|
|
||||
render partial: "shared/notification", locals: { type: type, message: message }
|
||||
end
|
||||
end
|
||||
|
||||
safe_join(notifications)
|
||||
end
|
||||
|
||||
##
|
||||
@@ -65,6 +76,20 @@ module ApplicationHelper
|
||||
end
|
||||
end
|
||||
|
||||
def mixed_hex_styles(hex)
|
||||
color = hex || "#1570EF" # blue-600
|
||||
|
||||
<<-STYLE.strip
|
||||
background-color: color-mix(in srgb, #{color} 5%, white);
|
||||
border-color: color-mix(in srgb, #{color} 10%, white);
|
||||
color: #{color};
|
||||
STYLE
|
||||
end
|
||||
|
||||
def circle_logo(name, hex: nil, size: "md")
|
||||
render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
|
||||
end
|
||||
|
||||
def return_to_path(params, fallback = root_path)
|
||||
uri = URI.parse(params[:return_to] || fallback)
|
||||
uri.relative? ? uri.path : root_path
|
||||
@@ -122,4 +147,11 @@ module ApplicationHelper
|
||||
options.reverse_merge!(money.default_format_options)
|
||||
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||
end
|
||||
|
||||
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
|
||||
collection.group_by(&:currency)
|
||||
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
|
||||
.map { |_currency, money| format_money(money) }
|
||||
.join(separator)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
module FormsHelper
|
||||
def styled_form_with(**options, &block)
|
||||
options[:builder] = StyledFormBuilder
|
||||
form_with(**options, &block)
|
||||
end
|
||||
|
||||
def modal_form_wrapper(title:, subtitle: nil, &block)
|
||||
content = capture &block
|
||||
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
|
||||
end
|
||||
|
||||
def form_field_tag(options = {}, &block)
|
||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||
tag.div **options, &block
|
||||
tag.div(**options, &block)
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
@@ -11,7 +22,60 @@ module FormsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
|
||||
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ], [ "All", "all" ] ]
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
|
||||
def money_with_currency_field(form, money_method, options = {})
|
||||
render partial: "shared/money_field", locals: {
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
default_currency: options[:default_currency] || "USD",
|
||||
disable_currency: options[:disable_currency] || false,
|
||||
hide_currency: options[:hide_currency] || false,
|
||||
label: options[:label] || "Amount"
|
||||
}
|
||||
end
|
||||
|
||||
def money_field(form, method, options = {})
|
||||
value = form.object ? form.object.send(method) : nil
|
||||
|
||||
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
|
||||
|
||||
# See "Monetizable" concern
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
|
||||
money_options = {
|
||||
value: value&.amount,
|
||||
placeholder: "100",
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = options.merge(money_options)
|
||||
|
||||
form.number_field money_amount_method, merged_options
|
||||
end
|
||||
|
||||
def currency_select_full(form, method, options = {}, html_options = {}, &block)
|
||||
choices = currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }
|
||||
form.select method, choices, options, html_options, &block
|
||||
end
|
||||
|
||||
def currency_select(form, method, options = {}, html_options = {}, &block)
|
||||
choices = currencies_for_select.map(&:iso_code)
|
||||
form.select method, choices, options, html_options, &block
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def currencies_for_select
|
||||
Money::Currency.all_instances
|
||||
.sort_by(&:priority)
|
||||
end
|
||||
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
|
||||
58
app/helpers/styled_form_builder.rb
Normal file
58
app/helpers/styled_form_builder.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# Fields that visually inherit from "text field"
|
||||
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
|
||||
|
||||
# Wraps "text" inputs with custom structure + base styles
|
||||
text_field_helpers.each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
input_html = label_html(method, options) + super(method, merged_options(options))
|
||||
input_html = apply_form_field_wrapper(input_html) unless options[:inline]
|
||||
input_html
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
super(method, tag_value, merged_options(options, "form-field__radio"))
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
input_html = label_html(method, options) + super(method, choices, options, merged_options(html_options))
|
||||
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
|
||||
input_html
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
input_html = label_html(method, options) + super(method, collection, value_method, text_method, options, merged_options(html_options))
|
||||
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
|
||||
input_html
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
super(value, merged_options(options, "form-field__submit"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_form_field_wrapper(input_html, **options)
|
||||
@template.form_field_tag(**options) do
|
||||
input_html
|
||||
end
|
||||
end
|
||||
|
||||
def merged_options(options, default_class = "form-field__input")
|
||||
combined_classes = options.fetch(:class, "") + " #{default_class}"
|
||||
style_options = { class: combined_classes }
|
||||
non_custom_options = options.except(:class, :label, :inline)
|
||||
style_options.merge(non_custom_options)
|
||||
end
|
||||
|
||||
def label_html(method, options)
|
||||
return label(method, class: "form-field__label") if options[:label] == true
|
||||
return "".html_safe unless options[:label]
|
||||
|
||||
label(method, options[:label], class: "form-field__label")
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
module Transactions::SearchesHelper
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def get_transaction_search_filter_partial_path(filter)
|
||||
"transactions/searches/filters/#{filter[:key]}"
|
||||
end
|
||||
|
||||
def get_default_transaction_search_filter
|
||||
transaction_search_filters[0]
|
||||
end
|
||||
|
||||
def transactions_path_without_param(param_key, param_value)
|
||||
updated_params = request.query_parameters.deep_dup
|
||||
|
||||
q_params = updated_params[:q] || {}
|
||||
|
||||
current_value = q_params[param_key]
|
||||
if current_value.is_a?(Array)
|
||||
q_params[param_key] = current_value - [ param_value ]
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
updated_params[:q] = q_params
|
||||
|
||||
transactions_path(updated_params)
|
||||
end
|
||||
end
|
||||
@@ -1,43 +1,37 @@
|
||||
module TransactionsHelper
|
||||
def transactions_group(date, transactions, transaction_partial_path = "transactions/transaction")
|
||||
header_left = content_tag :span do
|
||||
"#{date.strftime('%b %d, %Y')} · #{transactions.size}".html_safe
|
||||
end
|
||||
|
||||
header_right = content_tag :span do
|
||||
format_money(-transactions.sum(&:amount_money))
|
||||
end
|
||||
|
||||
header = header_left.concat(header_right)
|
||||
|
||||
content = render partial: transaction_partial_path, collection: transactions
|
||||
|
||||
render partial: "shared/list_group", locals: {
|
||||
header: header,
|
||||
content: content
|
||||
}
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(transaction)
|
||||
transaction.marked_as_transfer && transaction.transfer.nil?
|
||||
def get_transaction_search_filter_partial_path(filter)
|
||||
"transactions/searches/filters/#{filter[:key]}"
|
||||
end
|
||||
|
||||
def group_transactions_by_date(transactions)
|
||||
grouped_by_date = {}
|
||||
def get_default_transaction_search_filter
|
||||
transaction_search_filters[0]
|
||||
end
|
||||
|
||||
transactions.each do |transaction|
|
||||
if transaction.transfer
|
||||
transfer_date = transaction.transfer.inflow_transaction.date
|
||||
grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] }
|
||||
unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer)
|
||||
grouped_by_date[transfer_date][:transfers] << transaction.transfer
|
||||
end
|
||||
else
|
||||
grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] }
|
||||
grouped_by_date[transaction.date][:transactions] << transaction
|
||||
end
|
||||
def transactions_path_without_param(param_key, param_value)
|
||||
updated_params = request.query_parameters.deep_dup
|
||||
|
||||
q_params = updated_params[:q] || {}
|
||||
|
||||
current_value = q_params[param_key]
|
||||
if current_value.is_a?(Array)
|
||||
q_params[param_key] = current_value - [ param_value ]
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
grouped_by_date
|
||||
updated_params[:q] = q_params
|
||||
|
||||
transactions_path(updated_params)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
module ValueGroupsHelper
|
||||
def value_group_pie_data(value_group)
|
||||
value_group.children
|
||||
.map do |child|
|
||||
{
|
||||
label: to_accountable_title(Accountable.from_type(child.name)),
|
||||
percent_of_total: child.percent_of_total.round(1).to_f,
|
||||
value: child.sum.amount.to_f,
|
||||
currency: child.sum.currency.iso_code,
|
||||
bg_color: accountable_bg_class(child.name),
|
||||
fill_color: accountable_fill_class(child.name)
|
||||
}
|
||||
end
|
||||
.filter { |child| child[:value] > 0 }
|
||||
.to_json
|
||||
value_group.children.filter { |c| c.sum > 0 }.map do |child|
|
||||
{
|
||||
label: to_accountable_title(Accountable.from_type(child.name)),
|
||||
percent_of_total: child.percent_of_total.round(1).to_f,
|
||||
formatted_value: format_money(child.sum, precision: 0),
|
||||
bg_color: accountable_bg_class(child.name),
|
||||
fill_color: accountable_fill_class(child.name)
|
||||
}
|
||||
end.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ export default class extends Controller {
|
||||
submitBulkRequest(e) {
|
||||
const form = e.target.closest("form");
|
||||
const scope = e.params.scope
|
||||
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[transaction_ids][]`, this.selectedIdsValue)
|
||||
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue)
|
||||
form.requestSubmit()
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export default class extends Controller {
|
||||
|
||||
deselectAll() {
|
||||
this.selectedIdsValue = []
|
||||
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
|
||||
}
|
||||
|
||||
selectedIdsValueChanged() {
|
||||
|
||||
94
app/javascript/controllers/csv_upload_controller.js
Normal file
94
app/javascript/controllers/csv_upload_controller.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
||||
|
||||
connect() {
|
||||
this.submitTarget.disabled = true
|
||||
}
|
||||
|
||||
addFile(event) {
|
||||
const file = event.target.files[0]
|
||||
this._fileAdded(file)
|
||||
}
|
||||
|
||||
dragover(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.add("bg-gray-100")
|
||||
}
|
||||
|
||||
dragleave(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
}
|
||||
|
||||
drop(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && this._isCSVFile(file)) {
|
||||
this._setFileInput(file);
|
||||
this._fileAdded(file)
|
||||
} else {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "Only CSV files are allowed."
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
_fetchFileSize(size) {
|
||||
let fileSize = '';
|
||||
if (size < 1024 * 1024) {
|
||||
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
|
||||
} else {
|
||||
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
|
||||
}
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
_fileAdded(file) {
|
||||
const fileSizeLimit = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
if (file) {
|
||||
if (file.size > fileSizeLimit) {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
|
||||
return
|
||||
}
|
||||
|
||||
this.submitTarget.classList.remove([
|
||||
"bg-alpha-black-25",
|
||||
"text-gray",
|
||||
"cursor-not-allowed",
|
||||
]);
|
||||
this.submitTarget.classList.add(
|
||||
"bg-gray-900",
|
||||
"text-white",
|
||||
"cursor-pointer",
|
||||
);
|
||||
this.submitTarget.disabled = false;
|
||||
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
|
||||
this.previewTarget.classList.remove("text-red-500")
|
||||
this.previewTarget.classList.add("text-gray-900")
|
||||
this.filenameTarget.textContent = file.name;
|
||||
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
|
||||
}
|
||||
}
|
||||
|
||||
_isCSVFile(file) {
|
||||
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
return acceptedTypes.includes(file.type) || extension === "csv"
|
||||
}
|
||||
|
||||
_setFileInput(file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
this.inputTarget.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,23 @@ import { CurrenciesService } from "services/currencies_service";
|
||||
// Connects to data-controller="money-field"
|
||||
// when currency select change, update the input value with the correct placeholder and step
|
||||
export default class extends Controller {
|
||||
static targets = ["amount", "currency"];
|
||||
static targets = ["amount", "currency", "symbol"];
|
||||
|
||||
handleCurrencyChange() {
|
||||
const selectedCurrency = event.target.value;
|
||||
handleCurrencyChange(e) {
|
||||
const selectedCurrency = e.target.value;
|
||||
this.updateAmount(selectedCurrency);
|
||||
}
|
||||
|
||||
updateAmount(currency) {
|
||||
(new CurrenciesService).get(currency).then((data) => {
|
||||
this.amountTarget.placeholder = data.placeholder;
|
||||
this.amountTarget.step = data.step;
|
||||
(new CurrenciesService).get(currency).then((currency) => {
|
||||
console.log(currency)
|
||||
this.amountTarget.step = currency.step;
|
||||
|
||||
if (isFinite(this.amountTarget.value)) {
|
||||
this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision)
|
||||
}
|
||||
|
||||
this.symbolTarget.innerText = currency.symbol;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as d3 from "d3";
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Array,
|
||||
total: String,
|
||||
label: String,
|
||||
};
|
||||
|
||||
@@ -38,7 +39,7 @@ export default class extends Controller {
|
||||
|
||||
#draw() {
|
||||
this.#d3Container.attr("class", "relative");
|
||||
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
this.#d3Content.html(this.#contentSummaryTemplate());
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
@@ -75,23 +76,17 @@ export default class extends Controller {
|
||||
this.#d3Svg
|
||||
.selectAll(".arc path")
|
||||
.attr("class", (d) => d.data.fill_color);
|
||||
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
this.#d3ContentMemo.html(this.#contentSummaryTemplate());
|
||||
});
|
||||
}
|
||||
|
||||
#contentSummaryTemplate(data) {
|
||||
const total = data.reduce((acc, cur) => acc + cur.value, 0);
|
||||
const currency = data[0].currency;
|
||||
|
||||
return `${this.#currencyValue({
|
||||
value: total,
|
||||
currency,
|
||||
})} <span class="text-xs">${this.labelValue}</span>`;
|
||||
#contentSummaryTemplate() {
|
||||
return `<span class="text-xl text-gray-900 font-medium">${this.totalValue}</span> <span class="text-xs">${this.labelValue}</span>`;
|
||||
}
|
||||
|
||||
#contentDetailTemplate(datum) {
|
||||
return `
|
||||
<span>${this.#currencyValue(datum)}</span>
|
||||
<span class="text-xl text-gray-900 font-medium">${datum.formatted_value}</span>
|
||||
<div class="flex flex-row text-xs gap-2 items-center">
|
||||
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
|
||||
<span>${datum.label}</span>
|
||||
@@ -100,21 +95,6 @@ export default class extends Controller {
|
||||
`;
|
||||
}
|
||||
|
||||
#currencyValue(datum) {
|
||||
const formattedValue = Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(datum.value);
|
||||
|
||||
const firstDigitIndex = formattedValue.search(/\d/);
|
||||
const currencyPrefix = formattedValue.substring(0, firstDigitIndex);
|
||||
const mainPart = formattedValue.substring(firstDigitIndex);
|
||||
const [integerPart, fractionalPart] = mainPart.split(".");
|
||||
|
||||
return `<p class="text-gray-500 -space-x-0.5">${currencyPrefix}<span class="text-xl text-gray-900 font-medium">${integerPart}</span>.${fractionalPart}</p>`;
|
||||
}
|
||||
|
||||
get #radius() {
|
||||
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* A custom "select" element that follows accessibility patterns of a native select element.
|
||||
*
|
||||
* - If you need to display arbitrary content including non-clickable items, links, buttons, and forms, use the "popover" controller instead.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static classes = ["active"];
|
||||
static targets = ["option", "button", "list", "input", "buttonText"];
|
||||
static values = { selected: String };
|
||||
|
||||
initialize() {
|
||||
this.show = false;
|
||||
|
||||
const selectedElement = this.optionTargets.find(
|
||||
(option) => option.dataset.value === this.selectedValue
|
||||
);
|
||||
if (selectedElement) {
|
||||
this.updateAriaAttributesAndClasses(selectedElement);
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.syncButtonTextWithInput();
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.addEventListener("click", this.toggleList);
|
||||
}
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
this.element.addEventListener("turbo:load", this.handleTurboLoad);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.removeEventListener("click", this.toggleList);
|
||||
}
|
||||
}
|
||||
|
||||
selectedValueChanged() {
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
|
||||
handleOutsideClick = (event) => {
|
||||
if (this.show && !this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
handleTurboLoad = () => {
|
||||
this.close();
|
||||
this.syncButtonTextWithInput();
|
||||
};
|
||||
|
||||
handleKeydown = (event) => {
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
case "Enter":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
if (
|
||||
this.hasButtonTarget &&
|
||||
document.activeElement === this.buttonTarget
|
||||
) {
|
||||
this.toggleList();
|
||||
} else {
|
||||
this.selectOption(event);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
this.focusNextOption();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
this.focusPreviousOption();
|
||||
break;
|
||||
case "Escape":
|
||||
this.close();
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
focusNextOption() {
|
||||
this.focusOptionInDirection(1);
|
||||
}
|
||||
|
||||
focusPreviousOption() {
|
||||
this.focusOptionInDirection(-1);
|
||||
}
|
||||
|
||||
focusOptionInDirection(direction) {
|
||||
const currentFocusedIndex = this.optionTargets.findIndex(
|
||||
(option) => option === document.activeElement
|
||||
);
|
||||
const optionsCount = this.optionTargets.length;
|
||||
const nextIndex =
|
||||
(currentFocusedIndex + direction + optionsCount) % optionsCount;
|
||||
this.optionTargets[nextIndex].focus();
|
||||
}
|
||||
|
||||
toggleList = () => {
|
||||
if (!this.hasButtonTarget) return; // Ensure button target is present before toggling
|
||||
|
||||
this.show = !this.show;
|
||||
this.listTarget.classList.toggle("hidden", !this.show);
|
||||
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
|
||||
|
||||
if (this.show) {
|
||||
// Focus the first option or the selected option when the list is shown
|
||||
const selectedOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("aria-selected") === "true"
|
||||
);
|
||||
(selectedOption || this.optionTargets[0]).focus();
|
||||
}
|
||||
};
|
||||
|
||||
close() {
|
||||
if (this.hasButtonTarget) {
|
||||
this.show = false;
|
||||
this.listTarget.classList.add("hidden");
|
||||
this.buttonTarget.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
selectOption(event) {
|
||||
const selectedOption =
|
||||
event.type === "keydown" ? document.activeElement : event.currentTarget;
|
||||
this.updateAriaAttributesAndClasses(selectedOption);
|
||||
if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) {
|
||||
this.updateInputValueAndEmitEvent(selectedOption);
|
||||
}
|
||||
this.close(); // Close the list after selection
|
||||
}
|
||||
|
||||
updateAriaAttributesAndClasses(selectedOption) {
|
||||
this.optionTargets.forEach((option) => {
|
||||
option.setAttribute("aria-selected", "false");
|
||||
option.setAttribute("tabindex", "-1");
|
||||
option.classList.remove(...this.activeClasses);
|
||||
});
|
||||
selectedOption.classList.add(...this.activeClasses);
|
||||
selectedOption.setAttribute("aria-selected", "true");
|
||||
selectedOption.focus();
|
||||
}
|
||||
|
||||
updateInputValueAndEmitEvent(selectedOption) {
|
||||
// Update the hidden input's value
|
||||
const selectedValue = selectedOption.getAttribute("data-value");
|
||||
this.inputTarget.value = selectedValue;
|
||||
this.syncButtonTextWithInput();
|
||||
|
||||
// Emit an input event for auto-submit functionality
|
||||
const inputEvent = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
this.inputTarget.dispatchEvent(inputEvent);
|
||||
}
|
||||
|
||||
syncButtonTextWithInput() {
|
||||
const matchingOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("data-value") === this.inputTarget.value
|
||||
);
|
||||
if (matchingOption && this.hasButtonTextTarget) {
|
||||
this.buttonTextTarget.textContent = matchingOption.textContent.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
83
app/javascript/controllers/tooltip_controller.js
Normal file
83
app/javascript/controllers/tooltip_controller.js
Normal 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`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
64
app/javascript/controllers/trade_form_controller.js
Normal file
64
app/javascript/controllers/trade_form_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
class AccountSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account, start_date = nil)
|
||||
account.sync(start_date)
|
||||
def perform(account, start_date: nil)
|
||||
account.sync(start_date: start_date)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
class Account < ApplicationRecord
|
||||
include Syncable
|
||||
include Monetizable
|
||||
include Syncable, Monetizable, Issuable
|
||||
|
||||
broadcasts_refreshes
|
||||
|
||||
validates :family, presence: true
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :valuations, dependent: :destroy
|
||||
has_many :transactions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :syncs, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
@@ -25,82 +28,83 @@ class Account < ApplicationRecord
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
def balance_on(date)
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
delegate :value, :series, to: :accountable
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
types.each do |type|
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
self.where(accountable_type: type).each do |account|
|
||||
group.add_value_node(
|
||||
account,
|
||||
account.balance_money.exchange_to(currency, fallback_rate: 0),
|
||||
account.series(period: period, currency: currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
|
||||
# Always build the initial valuation
|
||||
account.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
|
||||
def 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
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
|
||||
def multi_currency?
|
||||
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
|
||||
currencies.count > 1
|
||||
end
|
||||
def update_balance!(balance)
|
||||
valuation = entries.account_valuations.find_by(date: Date.current)
|
||||
|
||||
# e.g. Accounts denominated in currency other than family currency
|
||||
def foreign_currency?
|
||||
currency != family.currency
|
||||
end
|
||||
|
||||
def self.by_provider
|
||||
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
|
||||
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
|
||||
end
|
||||
|
||||
def self.some_syncing?
|
||||
exists?(status: "syncing")
|
||||
end
|
||||
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
converted_balance = balance_money.exchange_to(currency)
|
||||
if converted_balance
|
||||
TimeSeries.new([ { date: Date.current, value: converted_balance } ])
|
||||
else
|
||||
TimeSeries.new([])
|
||||
end
|
||||
if valuation
|
||||
valuation.update! amount: balance
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def self.by_group(period: Period.all, currency: Money.default_currency)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
types.each do |type|
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
self.where(accountable_type: type).each do |account|
|
||||
value_node = group.add_value_node(
|
||||
account,
|
||||
account.balance_money.exchange_to(currency) || Money.new(0, currency),
|
||||
account.series(period: period, currency: currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def self.create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
|
||||
# Always build the initial valuation
|
||||
account.valuations.build(date: Date.current, value: attributes[:balance], currency: account.currency)
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.valuations.build(date: start_date, value: start_balance, currency: account.currency)
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
def holding_qty(security, date: Date.current)
|
||||
entries.account_trades
|
||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||
.where(account_trades: { security_id: security.id })
|
||||
.where("account_entries.date <= ?", date)
|
||||
.sum("account_trades.qty")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,4 +5,5 @@ class Account::Balance < ApplicationRecord
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
end
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
|
||||
def initialize(account, options = {})
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
end
|
||||
|
||||
def calculate
|
||||
prior_balance = implied_start_balance
|
||||
|
||||
calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date|
|
||||
valuation = normalized_valuations.find { |v| v["date"] == date }
|
||||
|
||||
if valuation
|
||||
current_balance = valuation["value"]
|
||||
else
|
||||
txn_flows = transaction_flows(date)
|
||||
current_balance = prior_balance - txn_flows
|
||||
end
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
{ date:, balance: current_balance, currency: @account.currency, updated_at: Time.current }
|
||||
end
|
||||
|
||||
@daily_balances = [
|
||||
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
||||
*calculated_balances
|
||||
]
|
||||
|
||||
if @account.foreign_currency?
|
||||
converted_balances = convert_balances_to_family_currency
|
||||
@daily_balances.concat(converted_balances)
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rates(
|
||||
@account.currency,
|
||||
@account.family.currency,
|
||||
@calc_start_date..Date.current
|
||||
).to_a
|
||||
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != @daily_balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
|
||||
@daily_balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
||||
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
|
||||
def normalize_entries_to_account_currency(entries, value_key)
|
||||
grouped_entries = entries.group_by(&:currency)
|
||||
normalized_entries = []
|
||||
|
||||
grouped_entries.each do |currency, entries|
|
||||
if currency != @account.currency
|
||||
dates = entries.map(&:date).uniq
|
||||
rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a
|
||||
if rates.length != dates.length
|
||||
@errors << :sync_message_missing_rates
|
||||
else
|
||||
entries.each do |entry|
|
||||
## There can be several entries on the same date so we cannot rely on indeces
|
||||
rate = rates.find { |rate| rate.date == entry.date }
|
||||
value = entry.send(value_key)
|
||||
value *= rate.rate
|
||||
normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
else
|
||||
normalized_entries.concat(entries)
|
||||
end
|
||||
end
|
||||
|
||||
normalized_entries
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
|
||||
end
|
||||
|
||||
def normalized_transactions
|
||||
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
|
||||
end
|
||||
|
||||
def transaction_flows(date)
|
||||
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
|
||||
flows *= -1 if @account.classification == "liability"
|
||||
flows
|
||||
end
|
||||
|
||||
def implied_start_balance
|
||||
if @calc_start_date > @account.effective_start_date
|
||||
return @account.balance_on(@calc_start_date)
|
||||
end
|
||||
|
||||
oldest_valuation_date = normalized_valuations.first&.date
|
||||
oldest_transaction_date = normalized_transactions.first&.date
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
|
||||
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
|
||||
oldest_valuation["value"].to_d
|
||||
else
|
||||
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
|
||||
net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
@account.balance.to_d + net_transaction_flows
|
||||
end
|
||||
end
|
||||
end
|
||||
132
app/models/account/balance/syncer.rb
Normal file
132
app/models/account/balance/syncer.rb
Normal file
@@ -0,0 +1,132 @@
|
||||
class Account::Balance::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@sync_start_date = calculate_sync_start_date(start_date)
|
||||
end
|
||||
|
||||
def run
|
||||
daily_balances = calculate_daily_balances
|
||||
daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency
|
||||
|
||||
Account::Balance.transaction do
|
||||
upsert_balances!(daily_balances)
|
||||
purge_stale_balances!
|
||||
|
||||
if daily_balances.any?
|
||||
account.reload
|
||||
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
end
|
||||
end
|
||||
rescue Money::ConversionError => e
|
||||
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :sync_start_date, :account
|
||||
|
||||
def upsert_balances!(balances)
|
||||
current_time = Time.now
|
||||
balances_to_upsert = balances.map do |balance|
|
||||
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
||||
end
|
||||
|
||||
def purge_stale_balances!
|
||||
account.balances.delete_by("date < ?", account_start_date)
|
||||
end
|
||||
|
||||
def calculate_balance_for_date(date, entries:, prior_balance:)
|
||||
valuation = entries.find { |e| e.date == date && e.account_valuation? }
|
||||
|
||||
return valuation.amount if valuation
|
||||
return derived_sync_start_balance(entries) unless prior_balance
|
||||
|
||||
entries = entries.select { |e| e.date == date }
|
||||
|
||||
prior_balance - net_entry_flows(entries)
|
||||
end
|
||||
|
||||
def calculate_daily_balances
|
||||
entries = account.entries.where("date >= ?", sync_start_date).to_a
|
||||
prior_balance = find_prior_balance
|
||||
|
||||
(sync_start_date..Date.current).map do |date|
|
||||
current_balance = calculate_balance_for_date(date, entries:, prior_balance:)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
build_balance(date, current_balance)
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_converted_balances(balances)
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
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 }
|
||||
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
|
||||
end
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
account.balances.build \
|
||||
date: date,
|
||||
balance: balance,
|
||||
currency: currency || account.currency
|
||||
end
|
||||
|
||||
def derived_sync_start_balance(entries)
|
||||
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
|
||||
|
||||
account.balance + net_entry_flows(transactions_and_trades)
|
||||
end
|
||||
|
||||
def find_prior_balance
|
||||
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
def net_entry_flows(entries, target_currency = account.currency)
|
||||
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
||||
|
||||
flows = converted_entry_amounts.sum(&:amount)
|
||||
|
||||
account.liability? ? flows * -1 : flows
|
||||
end
|
||||
|
||||
def account_start_date
|
||||
@account_start_date ||= begin
|
||||
oldest_entry_date = account.entries.chronological.first.try(:date)
|
||||
|
||||
return Date.current unless oldest_entry_date
|
||||
|
||||
oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists?
|
||||
|
||||
oldest_entry_date -= 1 unless oldest_entry_is_valuation
|
||||
oldest_entry_date
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(provided_start_date)
|
||||
[ provided_start_date, account_start_date ].compact.max
|
||||
end
|
||||
end
|
||||
218
app/models/account/entry.rb
Normal file
218
app/models/account/entry.rb
Normal file
@@ -0,0 +1,218 @@
|
||||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true
|
||||
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
validate :trade_valid?, if: -> { account_trade? }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
select(
|
||||
"account_entries.*",
|
||||
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
|
||||
}
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_entry&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(start_date: sync_start_date)
|
||||
end
|
||||
|
||||
def inflow?
|
||||
amount <= 0 && account_transaction?
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0 && account_transaction?
|
||||
end
|
||||
|
||||
def first_of_type?
|
||||
first_entry = account
|
||||
.entries
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(:date)
|
||||
.first
|
||||
|
||||
first_entry&.id == id
|
||||
end
|
||||
|
||||
def entryable_name_short
|
||||
entryable_type.demodulize.underscore
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
class << self
|
||||
# arbitrary cutoff date to avoid expensive sync operations
|
||||
def min_supported_date
|
||||
10.years.ago.to_date
|
||||
end
|
||||
|
||||
def daily_totals(entries, currency, period: Period.last_30_days)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(entries.with_converted_amount(currency), :e)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order(:date)
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Account::Transfer.new(entries: all).save if all.count == 2
|
||||
end
|
||||
|
||||
def bulk_update!(bulk_update_params)
|
||||
bulk_attributes = {
|
||||
date: bulk_update_params[:date],
|
||||
entryable_attributes: {
|
||||
notes: bulk_update_params[:notes],
|
||||
category_id: bulk_update_params[:category_id],
|
||||
merchant_id: bulk_update_params[:merchant_id]
|
||||
}.compact_blank
|
||||
}.compact_blank
|
||||
|
||||
return 0 if bulk_attributes.blank?
|
||||
|
||||
transaction do
|
||||
all.each do |entry|
|
||||
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
|
||||
entry.update! bulk_attributes
|
||||
end
|
||||
end
|
||||
|
||||
all.size
|
||||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount <= 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount > 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
|
||||
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
|
||||
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
|
||||
|
||||
if params[:accounts].present? || params[:account_ids].present?
|
||||
query = query.joins(:account)
|
||||
end
|
||||
|
||||
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
|
||||
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
|
||||
|
||||
# Search attributes on each entryable to further refine results
|
||||
entryable_ids = entryable_search(params)
|
||||
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entryable_search(params)
|
||||
entryable_ids = []
|
||||
entryable_search_performed = false
|
||||
|
||||
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
|
||||
next unless entryable.requires_search?(params)
|
||||
|
||||
entryable_search_performed = true
|
||||
entryable_ids += entryable.search(params).pluck(:id)
|
||||
end
|
||||
|
||||
return nil unless entryable_search_performed
|
||||
|
||||
entryable_ids
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_entry
|
||||
@previous_entry ||= account
|
||||
.entries
|
||||
.where("date < ?", date)
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: amount_money,
|
||||
previous: previous_entry&.amount_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
|
||||
def trade_valid?
|
||||
if account_trade.sell?
|
||||
current_qty = account.holding_qty(account_trade.security)
|
||||
|
||||
if current_qty < account_trade.qty.abs
|
||||
# i18n-tasks-use t('activerecord.errors.models.account/entry.attributes.base.invalid_sell_quantity')
|
||||
errors.add(
|
||||
:base,
|
||||
:invalid_sell_quantity,
|
||||
sell_qty: account_trade.qty.abs,
|
||||
ticker: account_trade.security.ticker,
|
||||
current_qty: current_qty
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
45
app/models/account/entry_builder.rb
Normal file
45
app/models/account/entry_builder.rb
Normal 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
|
||||
13
app/models/account/entryable.rb
Normal file
13
app/models/account/entryable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Account::Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
end
|
||||
|
||||
included do
|
||||
has_one :entry, as: :entryable, touch: true
|
||||
end
|
||||
end
|
||||
48
app/models/account/holding.rb
Normal file
48
app/models/account/holding.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Account::Holding < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, :currency, presence: true
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :current, -> { where(date: Date.current).order(amount: :desc) }
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :known_value, -> { where.not(amount: nil) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
|
||||
delegate :name, to: :security
|
||||
delegate :ticker, to: :security
|
||||
|
||||
def weight
|
||||
return nil unless amount
|
||||
|
||||
portfolio_value = account.holdings.current.known_value.sum(&:amount)
|
||||
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
|
||||
end
|
||||
|
||||
# Basic approximation of cost-basis
|
||||
def avg_cost
|
||||
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
|
||||
Money.new(avg_cost, currency)
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= calculate_trend
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_trend
|
||||
return nil unless amount_money
|
||||
|
||||
start_amount = qty * avg_cost
|
||||
|
||||
TimeSeries::Trend.new \
|
||||
current: amount_money,
|
||||
previous: start_amount
|
||||
end
|
||||
end
|
||||
127
app/models/account/holding/syncer.rb
Normal file
127
app/models/account/holding/syncer.rb
Normal file
@@ -0,0 +1,127 @@
|
||||
class Account::Holding::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
|
||||
@portfolio = {}
|
||||
|
||||
load_prior_portfolio if start_date
|
||||
end
|
||||
|
||||
def run
|
||||
holdings = []
|
||||
|
||||
sync_date_range.each do |date|
|
||||
holdings += build_holdings_for_date(date)
|
||||
end
|
||||
|
||||
upsert_holdings holdings
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :account, :sync_date_range
|
||||
|
||||
def sync_entries
|
||||
@sync_entries ||= account.entries
|
||||
.account_trades
|
||||
.includes(entryable: :security)
|
||||
.where("date >= ?", sync_date_range.begin)
|
||||
.order(:date)
|
||||
end
|
||||
|
||||
def get_cached_price(ticker, date)
|
||||
return nil unless security_prices.key?(ticker)
|
||||
|
||||
price = security_prices[ticker].find { |p| p.date == date }
|
||||
price ? price[:price] : nil
|
||||
end
|
||||
|
||||
def security_prices
|
||||
@security_prices ||= begin
|
||||
prices = {}
|
||||
ticker_start_dates = {}
|
||||
|
||||
sync_entries.each do |entry|
|
||||
unless ticker_start_dates[entry.account_trade.security.ticker]
|
||||
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
|
||||
end
|
||||
end
|
||||
|
||||
ticker_start_dates.each do |ticker, date|
|
||||
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |ticker, holding|
|
||||
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
|
||||
trade_price = trade&.account_trade&.price
|
||||
|
||||
price = get_cached_price(ticker, date) || trade_price
|
||||
|
||||
account.observe_missing_price(ticker:, date:) unless price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
security_id: holding[:security_id],
|
||||
qty: holding[:qty],
|
||||
price: price,
|
||||
amount: price ? (price * holding[:qty]) : nil,
|
||||
currency: holding[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
def generate_next_portfolio(prior_portfolio, trade_entries)
|
||||
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.ticker] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
currency: entry.currency,
|
||||
security_id: trade.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_holdings(holdings)
|
||||
current_time = Time.now
|
||||
holdings_to_upsert = holdings.map do |holding|
|
||||
holding.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
|
||||
end
|
||||
|
||||
def load_prior_portfolio
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.ticker] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
currency: holding.currency,
|
||||
security_id: holding.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(start_date)
|
||||
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
end
|
||||
82
app/models/account/sync.rb
Normal file
82
app/models/account/sync.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
class Account::Sync < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
|
||||
|
||||
class << self
|
||||
def for(account, start_date: nil)
|
||||
create! account: account, start_date: start_date
|
||||
end
|
||||
|
||||
def latest
|
||||
order(created_at: :desc).first
|
||||
end
|
||||
end
|
||||
|
||||
def run
|
||||
start!
|
||||
|
||||
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
|
||||
Account::Balance::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
Account::Holding::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def start!
|
||||
update! status: "syncing", last_ran_at: Time.now
|
||||
broadcast_start
|
||||
end
|
||||
|
||||
def complete!
|
||||
update! status: "completed"
|
||||
|
||||
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: I18n.t("account.sync.failed")
|
||||
end
|
||||
|
||||
def broadcast_start
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { id: id, type: "processing", message: "Syncing account balances" }
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_result(type:, message:)
|
||||
broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { type: type, message: message }
|
||||
)
|
||||
|
||||
account.family.broadcast_refresh
|
||||
end
|
||||
end
|
||||
@@ -1,85 +1,29 @@
|
||||
module Account::Syncable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
|
||||
rescue => e
|
||||
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
|
||||
logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
class_methods do
|
||||
def sync(start_date: nil)
|
||||
all.each { |a| a.sync_later(start_date: start_date) }
|
||||
end
|
||||
end
|
||||
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
def syncing?
|
||||
syncs.syncing.any?
|
||||
end
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
def latest_sync_date
|
||||
syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date
|
||||
end
|
||||
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
def needs_sync?
|
||||
latest_sync_date.nil? || latest_sync_date < Date.current
|
||||
end
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
def sync_later(start_date: nil)
|
||||
AccountSyncJob.perform_later(self, start_date: start_date)
|
||||
end
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
end
|
||||
|
||||
nil
|
||||
def sync(start_date: nil)
|
||||
Account::Sync.for(self, start_date: start_date).run
|
||||
end
|
||||
end
|
||||
|
||||
28
app/models/account/trade.rb
Normal file
28
app/models/account/trade.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class Account::Trade < ApplicationRecord
|
||||
include Account::Entryable, Monetizable
|
||||
|
||||
monetize :price
|
||||
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, presence: true, numericality: { other_than: 0 }
|
||||
validates :price, :currency, presence: true
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def sell?
|
||||
qty < 0
|
||||
end
|
||||
|
||||
def buy?
|
||||
qty > 0
|
||||
end
|
||||
end
|
||||
46
app/models/account/trade_builder.rb
Normal file
46
app/models/account/trade_builder.rb
Normal 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
|
||||
40
app/models/account/transaction.rb
Normal file
40
app/models/account/transaction.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class Account::Transaction < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
scope :active, -> { where(excluded: false) }
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
|
||||
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
query
|
||||
end
|
||||
|
||||
def requires_search?(params)
|
||||
searchable_keys.any? { |key| params.key?(key) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[ categories merchants ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
end
|
||||
end
|
||||
63
app/models/account/transaction_builder.rb
Normal file
63
app/models/account/transaction_builder.rb
Normal 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
|
||||
@@ -1,21 +1,41 @@
|
||||
class Account::Transfer < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
has_many :entries, dependent: :nullify
|
||||
|
||||
validate :net_zero_flows, if: :single_currency_transfer?
|
||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
||||
|
||||
def date
|
||||
outflow_transaction&.date
|
||||
end
|
||||
|
||||
def amount_money
|
||||
entries.first&.amount_money&.abs || Money.new(0)
|
||||
end
|
||||
|
||||
def from_name
|
||||
outflow_transaction&.account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
inflow_transaction&.account&.name || I18n.t("account/transfer.to_fallback_name")
|
||||
end
|
||||
|
||||
def name
|
||||
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
transactions.find { |t| t.inflow? }
|
||||
entries.find { |e| e.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
transactions.find { |t| t.outflow? }
|
||||
entries.find { |e| e.outflow? }
|
||||
end
|
||||
|
||||
def destroy_and_remove_marks!
|
||||
transaction do
|
||||
transactions.each do |t|
|
||||
t.update! marked_as_transfer: false
|
||||
entries.each do |e|
|
||||
e.update! marked_as_transfer: false
|
||||
end
|
||||
|
||||
destroy!
|
||||
@@ -24,39 +44,56 @@ class Account::Transfer < ApplicationRecord
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new transactions: [ outflow, inflow ]
|
||||
inflow = to_account.entries.build \
|
||||
amount: amount.abs * -1,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new entries: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
transactions.map(&:currency).uniq.size == 1
|
||||
entries.map { |e| e.currency }.uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless transactions.size == 2
|
||||
errors.add :transactions, "must have exactly 2 transactions"
|
||||
unless entries.size == 2
|
||||
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_exactly_2_entries')
|
||||
errors.add :entries, :must_have_exactly_2_entries
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = transactions.map(&:account_id).uniq
|
||||
errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size
|
||||
accounts = entries.map { |e| e.account_id }.uniq
|
||||
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_from_different_accounts')
|
||||
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless transactions.sum(&:amount).zero?
|
||||
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||
unless entries.sum(&:amount).zero?
|
||||
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_an_inflow_and_outflow_that_net_to_zero')
|
||||
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless transactions.all?(&:marked_as_transfer)
|
||||
errors.add :transactions, "must be marked as transfer"
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_marked_as_transfer')
|
||||
errors.add :entries, :must_be_marked_as_transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,52 +1,13 @@
|
||||
class Account::Valuation < ApplicationRecord
|
||||
include Monetizable
|
||||
include Account::Entryable
|
||||
|
||||
monetize :value
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :account, :date, :value, presence: true
|
||||
validates :date, uniqueness: { scope: :account_id }
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :reverse_chronological, -> { order(date: :desc) }
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
def first_of_series?
|
||||
account.valuations.chronological.limit(1).pluck(:date).first == self.date
|
||||
end
|
||||
|
||||
def last_of_series?
|
||||
account.valuations.reverse_chronological.limit(1).pluck(:date).first == self.date
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_valuation&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_valuation
|
||||
@previous_valuation ||= self.account
|
||||
.valuations
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: self.value,
|
||||
previous: previous_valuation&.value,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
||||
@@ -17,4 +17,20 @@ module Accountable
|
||||
included do
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
def value
|
||||
account.balance_money
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
58
app/models/concerns/issuable.rb
Normal file
58
app/models/concerns/issuable.rb
Normal 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
|
||||
@@ -6,7 +6,7 @@ module Monetizable
|
||||
fields.each do |field|
|
||||
define_method("#{field}_money") do
|
||||
value = self.send(field)
|
||||
value.nil? ? nil : Money.new(value, currency)
|
||||
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,12 +6,27 @@ module Providable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def security_prices_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def exchange_rates_provider
|
||||
Provider::Synth.new
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def git_repository_provider
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def synth_provider
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
|
||||
delegate :family, to: :user
|
||||
delegate :family, to: :user, allow_nil: true
|
||||
end
|
||||
|
||||
351
app/models/demo/generator.rb
Normal file
351
app/models/demo/generator.rb
Normal file
@@ -0,0 +1,351 @@
|
||||
class Demo::Generator
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
def initialize
|
||||
@family = reset_family!
|
||||
end
|
||||
|
||||
def reset_and_clear_data!
|
||||
clear_data!
|
||||
create_user!
|
||||
|
||||
puts "user reset"
|
||||
end
|
||||
|
||||
def reset_data!
|
||||
Family.transaction do
|
||||
clear_data!
|
||||
create_user!
|
||||
|
||||
puts "user reset"
|
||||
|
||||
create_tags!
|
||||
create_categories!
|
||||
create_merchants!
|
||||
|
||||
puts "tags, categories, merchants created"
|
||||
|
||||
create_credit_card_account!
|
||||
create_checking_account!
|
||||
create_savings_account!
|
||||
|
||||
create_investment_account!
|
||||
create_house_and_mortgage!
|
||||
create_car_and_loan!
|
||||
|
||||
puts "accounts created"
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :family
|
||||
|
||||
def reset_family!
|
||||
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
|
||||
|
||||
family = Family.find_by(id: family_id)
|
||||
family.destroy! if family
|
||||
|
||||
Family.create!(id: family_id, name: "Demo Family").tap(&:reload)
|
||||
end
|
||||
|
||||
def clear_data!
|
||||
User.find_by_email("user@maybe.local")&.destroy
|
||||
ExchangeRate.destroy_all
|
||||
Security.destroy_all
|
||||
Security::Price.destroy_all
|
||||
end
|
||||
|
||||
def create_user!
|
||||
family.users.create! \
|
||||
email: "user@maybe.local",
|
||||
first_name: "Demo",
|
||||
last_name: "User",
|
||||
password: "password"
|
||||
end
|
||||
|
||||
def create_tags!
|
||||
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
|
||||
family.tags.create!(name: tag)
|
||||
end
|
||||
end
|
||||
|
||||
def create_categories!
|
||||
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
|
||||
"Personal Care", "General Services", "Auto & Transport",
|
||||
"Rent & Utilities", "Home Improvement", "Shopping" ]
|
||||
|
||||
categories.each do |category|
|
||||
family.categories.create!(name: category, color: COLORS.sample)
|
||||
end
|
||||
end
|
||||
|
||||
def create_merchants!
|
||||
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
|
||||
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
|
||||
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
|
||||
|
||||
merchants.each do |merchant|
|
||||
family.merchants.create!(name: merchant, color: COLORS.sample)
|
||||
end
|
||||
end
|
||||
|
||||
def create_credit_card_account!
|
||||
cc = family.accounts.create! \
|
||||
accountable: CreditCard.new,
|
||||
name: "Chase Credit Card",
|
||||
balance: 2300,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
|
||||
50.times do
|
||||
merchant = random_family_record(Merchant)
|
||||
create_transaction! \
|
||||
account: cc,
|
||||
name: merchant.name,
|
||||
amount: Faker::Number.positive(to: 200),
|
||||
tags: [ tag_for_merchant(merchant) ],
|
||||
category: category_for_merchant(merchant),
|
||||
merchant: merchant
|
||||
end
|
||||
|
||||
5.times do
|
||||
create_transaction! \
|
||||
account: cc,
|
||||
amount: Faker::Number.negative(from: -1000),
|
||||
name: "CC Payment"
|
||||
end
|
||||
end
|
||||
|
||||
def create_checking_account!
|
||||
checking = family.accounts.create! \
|
||||
accountable: Depository.new,
|
||||
name: "Chase Checking",
|
||||
balance: 15000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
|
||||
10.times do
|
||||
create_transaction! \
|
||||
account: checking,
|
||||
name: "Expense",
|
||||
amount: Faker::Number.positive(from: 100, to: 1000)
|
||||
end
|
||||
|
||||
10.times do
|
||||
create_transaction! \
|
||||
account: checking,
|
||||
amount: Faker::Number.negative(from: -2000),
|
||||
name: "Income",
|
||||
category: income_category
|
||||
end
|
||||
end
|
||||
|
||||
def create_savings_account!
|
||||
savings = family.accounts.create! \
|
||||
accountable: Depository.new,
|
||||
name: "Demo Savings",
|
||||
balance: 40000,
|
||||
currency: "USD",
|
||||
subtype: "savings",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
|
||||
income_category = categories.find { |c| c.name == "Income" }
|
||||
income_tag = tags.find { |t| t.name == "Emergency Fund" }
|
||||
|
||||
20.times do
|
||||
create_transaction! \
|
||||
account: savings,
|
||||
amount: Faker::Number.negative(from: -2000),
|
||||
tags: [ income_tag ],
|
||||
category: income_category,
|
||||
name: "Income"
|
||||
end
|
||||
end
|
||||
|
||||
def load_securities!
|
||||
# Create an unknown security to simulate edge cases
|
||||
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock"
|
||||
|
||||
securities = [
|
||||
{ ticker: "AAPL", name: "Apple Inc.", reference_price: 210 },
|
||||
{ ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
|
||||
{ ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
|
||||
]
|
||||
|
||||
securities.each do |security_attributes|
|
||||
security = Security.create! security_attributes.except(:reference_price)
|
||||
|
||||
# Load prices for last 2 years
|
||||
(730.days.ago.to_date..Date.current).each do |date|
|
||||
reference = security_attributes[:reference_price]
|
||||
low_price = reference - 20
|
||||
high_price = reference + 20
|
||||
Security::Price.create! \
|
||||
ticker: security.ticker,
|
||||
date: date,
|
||||
price: Faker::Number.positive(from: low_price, to: high_price)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_investment_account!
|
||||
load_securities!
|
||||
|
||||
account = family.accounts.create! \
|
||||
accountable: Investment.new,
|
||||
name: "Robinhood",
|
||||
balance: 100000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||
|
||||
aapl = Security.find_by(ticker: "AAPL")
|
||||
tm = Security.find_by(ticker: "TM")
|
||||
msft = Security.find_by(ticker: "MSFT")
|
||||
unknown = Security.find_by(ticker: "UNKNOWN")
|
||||
|
||||
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
|
||||
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
|
||||
|
||||
trades = [
|
||||
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
||||
{ security: msft, qty: -5 }, { security: tm, qty: 10 }, { security: msft, qty: 5 },
|
||||
{ security: tm, qty: 10 }, { security: aapl, qty: -5 }, { security: msft, qty: -5 },
|
||||
{ security: tm, qty: 10 }, { security: msft, qty: 5 }, { security: aapl, qty: -10 }
|
||||
]
|
||||
|
||||
trades.each do |trade|
|
||||
date = Faker::Number.positive(to: 730).days.ago.to_date
|
||||
security = trade[:security]
|
||||
qty = trade[:qty]
|
||||
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
|
||||
name_prefix = qty < 0 ? "Sell " : "Buy "
|
||||
|
||||
account.entries.create! \
|
||||
date: date,
|
||||
amount: qty * price,
|
||||
currency: "USD",
|
||||
name: name_prefix + "#{qty} shares of #{security.ticker}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
|
||||
end
|
||||
end
|
||||
|
||||
def create_house_and_mortgage!
|
||||
house = family.accounts.create! \
|
||||
accountable: Property.new,
|
||||
name: "123 Maybe Way",
|
||||
balance: 560000,
|
||||
currency: "USD"
|
||||
|
||||
create_valuation!(house, 3.years.ago.to_date, 520000)
|
||||
create_valuation!(house, 2.years.ago.to_date, 540000)
|
||||
create_valuation!(house, 1.years.ago.to_date, 550000)
|
||||
|
||||
family.accounts.create! \
|
||||
accountable: Loan.new,
|
||||
name: "Mortgage",
|
||||
balance: 495000,
|
||||
currency: "USD"
|
||||
end
|
||||
|
||||
def create_car_and_loan!
|
||||
family.accounts.create! \
|
||||
accountable: Vehicle.new,
|
||||
name: "Honda Accord",
|
||||
balance: 18000,
|
||||
currency: "USD"
|
||||
|
||||
family.accounts.create! \
|
||||
accountable: Loan.new,
|
||||
name: "Car Loan",
|
||||
balance: 8000,
|
||||
currency: "USD"
|
||||
end
|
||||
|
||||
def create_transaction!(attributes = {})
|
||||
entry_attributes = attributes.except(:category, :tags, :merchant)
|
||||
transaction_attributes = attributes.slice(:category, :tags, :merchant)
|
||||
|
||||
entry_defaults = {
|
||||
date: Faker::Number.between(from: 0, to: 90).days.ago.to_date,
|
||||
currency: "USD",
|
||||
entryable: Account::Transaction.new(transaction_attributes)
|
||||
}
|
||||
|
||||
Account::Entry.create! entry_defaults.merge(entry_attributes)
|
||||
end
|
||||
|
||||
def create_valuation!(account, date, amount)
|
||||
Account::Entry.create! \
|
||||
account: account,
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
def random_family_record(model)
|
||||
family_records = model.where(family_id: family.id)
|
||||
model.offset(rand(family_records.count)).first
|
||||
end
|
||||
|
||||
def category_for_merchant(merchant)
|
||||
mapping = {
|
||||
"Amazon" => "Shopping",
|
||||
"Starbucks" => "Food & Drink",
|
||||
"McDonald's" => "Food & Drink",
|
||||
"Target" => "Shopping",
|
||||
"Costco" => "Food & Drink",
|
||||
"Home Depot" => "Home Improvement",
|
||||
"Shell" => "Auto & Transport",
|
||||
"Whole Foods" => "Food & Drink",
|
||||
"Walgreens" => "Personal Care",
|
||||
"Nike" => "Shopping",
|
||||
"Uber" => "Auto & Transport",
|
||||
"Netflix" => "Entertainment",
|
||||
"Spotify" => "Entertainment",
|
||||
"Delta Airlines" => "Travel",
|
||||
"Airbnb" => "Travel",
|
||||
"Sephora" => "Personal Care"
|
||||
}
|
||||
|
||||
categories.find { |c| c.name == mapping[merchant.name] }
|
||||
end
|
||||
|
||||
def tag_for_merchant(merchant)
|
||||
mapping = {
|
||||
"Delta Airlines" => "Trips",
|
||||
"Airbnb" => "Trips"
|
||||
}
|
||||
|
||||
tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] }
|
||||
|
||||
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
|
||||
end
|
||||
|
||||
def securities
|
||||
@securities ||= Security.all.to_a
|
||||
end
|
||||
|
||||
def merchants
|
||||
@merchants ||= family.merchants
|
||||
end
|
||||
|
||||
def categories
|
||||
@categories ||= family.categories
|
||||
end
|
||||
|
||||
def tags
|
||||
@tags ||= family.tags
|
||||
end
|
||||
|
||||
def income_tag
|
||||
@income_tag ||= tags.find { |t| t.name == "Emergency Fund" }
|
||||
end
|
||||
|
||||
def income_category
|
||||
@income_category ||= categories.find { |c| c.name == "Income" }
|
||||
end
|
||||
end
|
||||
@@ -1,29 +1,28 @@
|
||||
class ExchangeRate < ApplicationRecord
|
||||
include Provided
|
||||
|
||||
validates :base_currency, :converted_currency, presence: true
|
||||
validates :from_currency, :to_currency, :date, :rate, presence: true
|
||||
|
||||
class << self
|
||||
def find_rate(from:, to:, date:)
|
||||
find_by \
|
||||
base_currency: Money::Currency.new(from).iso_code,
|
||||
converted_currency: Money::Currency.new(to).iso_code,
|
||||
def find_rate(from:, to:, date:, cache: true)
|
||||
result = find_by \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: date
|
||||
|
||||
result || fetch_rate_from_provider(from:, to:, date:, cache:)
|
||||
end
|
||||
|
||||
def find_rate_or_fetch(from:, to:, date:)
|
||||
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!)
|
||||
end
|
||||
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
|
||||
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a
|
||||
existing_dates = rates.map(&:date)
|
||||
missing_dates = all_dates - existing_dates
|
||||
if missing_dates.any?
|
||||
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
|
||||
end
|
||||
|
||||
def get_rates(from, to, dates)
|
||||
where(base_currency: from, converted_currency: to, date: dates).order(:date)
|
||||
end
|
||||
|
||||
def convert(value:, from:, to:, date:)
|
||||
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date:)
|
||||
raise "Conversion from: #{from} to: #{to} on: #{date} not found" unless rate
|
||||
|
||||
value * rate.rate
|
||||
rates
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +1,61 @@
|
||||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Providable
|
||||
|
||||
class_methods do
|
||||
def provider_healthy?
|
||||
exchange_rates_provider.present? && exchange_rates_provider.healthy?
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_rate_from_provider(from:, to:, date:)
|
||||
return nil unless exchange_rates_provider.configured?
|
||||
|
||||
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
|
||||
return [] unless exchange_rates_provider.present?
|
||||
|
||||
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)
|
||||
return nil unless exchange_rates_provider.present?
|
||||
|
||||
response = exchange_rates_provider.fetch_exchange_rate \
|
||||
from: Money::Currency.new(from).iso_code,
|
||||
to: Money::Currency.new(to).iso_code,
|
||||
from: from,
|
||||
to: to,
|
||||
date: date
|
||||
|
||||
if response.success?
|
||||
ExchangeRate.new \
|
||||
base_currency: from,
|
||||
converted_currency: to,
|
||||
rate = ExchangeRate.new \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
rate: response.rate,
|
||||
date: date
|
||||
|
||||
rate.save! if cache
|
||||
rate
|
||||
else
|
||||
raise response.error
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,9 +4,11 @@ class Family < ApplicationRecord
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :entries, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
has_many :issues, through: :accounts
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
@@ -34,17 +36,18 @@ class Family < ApplicationRecord
|
||||
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:transactions)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("transactions.date >= ?", period.date_range.begin)
|
||||
.where("transactions.date <= ?", period.date_range.end)
|
||||
.where("transactions.marked_as_transfer = ?", false)
|
||||
.group("id")
|
||||
.to_a
|
||||
results = accounts.active.joins(:entries)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
|
||||
)
|
||||
.where("account_entries.date >= ?", period.date_range.begin)
|
||||
.where("account_entries.date <= ?", period.date_range.end)
|
||||
.where("account_entries.marked_as_transfer = ?", false)
|
||||
.where("account_entries.entryable_type = ?", "Account::Transaction")
|
||||
.group("id")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
r.define_singleton_method(:savings_rate) do
|
||||
@@ -60,7 +63,8 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def snapshot_transactions
|
||||
rolling_totals = Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
|
||||
candidate_entries = entries.account_transactions.without_transfers
|
||||
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
|
||||
|
||||
spending = []
|
||||
income = []
|
||||
@@ -89,23 +93,29 @@ class Family < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def effective_start_date
|
||||
accounts.active.joins(:balances).minimum("account_balances.date") || Date.current
|
||||
end
|
||||
|
||||
def net_worth
|
||||
assets - liabilities
|
||||
end
|
||||
|
||||
def assets
|
||||
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
|
||||
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
|
||||
end
|
||||
|
||||
def liabilities
|
||||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
|
||||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
|
||||
end
|
||||
|
||||
def sync_accounts
|
||||
accounts.each { |account| account.sync_later if account.can_sync? }
|
||||
def sync(start_date: nil)
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync_later(start_date: start_date || account.last_sync_date)
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def needs_sync?
|
||||
last_synced_at.nil? || last_synced_at.to_date < Date.current
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class Import < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validate :raw_csv_must_be_parsable
|
||||
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
|
||||
|
||||
before_save :initialize_csv, if: :should_initialize_csv?
|
||||
|
||||
@@ -38,7 +39,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def get_selected_header_for_field(field)
|
||||
column_mappings&.dig(field) || field.key
|
||||
column_mappings&.dig(field.key) || field.key
|
||||
end
|
||||
|
||||
def update_csv!(row_idx:, col_idx:, value:)
|
||||
@@ -88,7 +89,7 @@ class Import < ApplicationRecord
|
||||
|
||||
def get_raw_csv
|
||||
return nil if raw_csv_str.nil?
|
||||
Import::Csv.new(raw_csv_str)
|
||||
Import::Csv.new(raw_csv_str, col_sep:)
|
||||
end
|
||||
|
||||
def should_initialize_csv?
|
||||
@@ -102,7 +103,7 @@ class Import < ApplicationRecord
|
||||
|
||||
# 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)
|
||||
@@ -111,7 +112,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def generate_transactions
|
||||
transactions = []
|
||||
transaction_entries = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
@@ -126,18 +127,17 @@ class Import < ApplicationRecord
|
||||
|
||||
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
txn = account.transactions.build \
|
||||
entry = account.entries.build \
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
category: category,
|
||||
tags: tags,
|
||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||
currency: account.currency
|
||||
currency: account.currency,
|
||||
amount: BigDecimal(row["amount"]) * -1,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags)
|
||||
|
||||
transactions << txn
|
||||
transaction_entries << entry
|
||||
end
|
||||
|
||||
transactions
|
||||
transaction_entries
|
||||
end
|
||||
|
||||
def create_expected_fields
|
||||
@@ -177,9 +177,10 @@ class Import < ApplicationRecord
|
||||
|
||||
def raw_csv_must_be_parsable
|
||||
begin
|
||||
CSV.parse(raw_csv_str || "")
|
||||
CSV.parse(raw_csv_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_csv_str.invalid_csv_format')
|
||||
errors.add(:raw_csv_str, :invalid_csv_format)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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_csv_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
|
||||
raw_csv = self.parse_csv(raw_csv_str, col_sep:)
|
||||
|
||||
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true 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)
|
||||
|
||||
@@ -13,4 +13,35 @@ class Investment < ApplicationRecord
|
||||
[ "Roth 401k", "roth_401k" ],
|
||||
[ "Angel", "angel" ]
|
||||
].freeze
|
||||
|
||||
def value
|
||||
account.balance_money + holdings_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
|
||||
|
||||
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
combined_series = balance_series.map do |balance|
|
||||
holding_amount = holdings_by_date[balance.date] || 0
|
||||
|
||||
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
|
||||
end
|
||||
|
||||
if combined_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.new(combined_series)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
35
app/models/issue.rb
Normal file
35
app/models/issue.rb
Normal 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
|
||||
9
app/models/issue/exchange_rate_provider_missing.rb
Normal file
9
app/models/issue/exchange_rate_provider_missing.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class Issue::ExchangeRateProviderMissing < Issue
|
||||
def default_severity
|
||||
:error
|
||||
end
|
||||
|
||||
def stale?
|
||||
ExchangeRate.provider_healthy?
|
||||
end
|
||||
end
|
||||
15
app/models/issue/exchange_rates_missing.rb
Normal file
15
app/models/issue/exchange_rates_missing.rb
Normal 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
|
||||
33
app/models/issue/prices_missing.rb
Normal file
33
app/models/issue/prices_missing.rb
Normal 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
|
||||
11
app/models/issue/unknown.rb
Normal file
11
app/models/issue/unknown.rb
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
class Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
class Provider::Synth
|
||||
include Retryable
|
||||
|
||||
def initialize(api_key = ENV["SYNTH_API_KEY"])
|
||||
@api_key = api_key || ENV["SYNTH_API_KEY"]
|
||||
def initialize(api_key)
|
||||
@api_key = api_key
|
||||
end
|
||||
|
||||
def configured?
|
||||
@api_key.present?
|
||||
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",
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
) do |body|
|
||||
body.dig("prices").map do |price|
|
||||
{
|
||||
date: price.dig("date"),
|
||||
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
|
||||
currency: "USD"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
SecurityPriceResponse.new \
|
||||
prices: prices,
|
||||
success?: true,
|
||||
raw_response: prices.to_json
|
||||
rescue StandardError => error
|
||||
SecurityPriceResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
retrying Provider::Base.known_transient_errors do |on_last_attempt|
|
||||
response = Faraday.get("#{base_url}/rates/historical") do |req|
|
||||
req.headers["Authorization"] = "Bearer #{api_key}"
|
||||
response = client.get("#{base_url}/rates/historical") do |req|
|
||||
req.params["date"] = date.to_s
|
||||
req.params["from"] = from
|
||||
req.params["to"] = to
|
||||
@@ -36,20 +62,100 @@ class Provider::Synth
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
exchange_rates = paginate(
|
||||
"#{base_url}/rates/historical-range",
|
||||
from: from,
|
||||
to: to,
|
||||
date_start: start_date.to_s,
|
||||
date_end: end_date.to_s
|
||||
) do |body|
|
||||
body.dig("data").map do |exchange_rate|
|
||||
{
|
||||
date: exchange_rate.dig("date"),
|
||||
rate: exchange_rate.dig("rates", to)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
ExchangeRatesResponse.new \
|
||||
rates: exchange_rates,
|
||||
success?: true,
|
||||
raw_response: exchange_rates.to_json
|
||||
rescue StandardError => error
|
||||
ExchangeRatesResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key
|
||||
|
||||
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
|
||||
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
|
||||
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
|
||||
|
||||
def base_url
|
||||
"https://api.synthfinance.com"
|
||||
end
|
||||
|
||||
def app_name
|
||||
"maybe_app"
|
||||
end
|
||||
|
||||
def app_type
|
||||
Rails.application.config.app_mode
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Faraday.new(url: base_url) do |faraday|
|
||||
faraday.headers["Authorization"] = "Bearer #{api_key}"
|
||||
faraday.headers["X-Source"] = app_name
|
||||
faraday.headers["X-Source-Type"] = app_type
|
||||
end
|
||||
end
|
||||
|
||||
def build_error(response)
|
||||
Provider::Base::ProviderError.new(<<~ERROR)
|
||||
Failed to fetch exchange rate from #{self.class}
|
||||
Failed to fetch data from #{self.class}
|
||||
Status: #{response.status}
|
||||
Body: #{response.body.inspect}
|
||||
ERROR
|
||||
end
|
||||
|
||||
def fetch_page(url, page, params = {})
|
||||
client.get(url) do |req|
|
||||
req.headers["Authorization"] = "Bearer #{api_key}"
|
||||
params.each { |k, v| req.params[k.to_s] = v.to_s }
|
||||
req.params["page"] = page
|
||||
end
|
||||
end
|
||||
|
||||
def paginate(url, params = {})
|
||||
results = []
|
||||
page = 1
|
||||
current_page = 0
|
||||
total_pages = 1
|
||||
|
||||
while current_page < total_pages
|
||||
response = fetch_page(url, page, params)
|
||||
|
||||
if response.success?
|
||||
body = JSON.parse(response.body)
|
||||
page_results = yield(body)
|
||||
results.concat(page_results)
|
||||
|
||||
current_page = body.dig("paging", "current_page")
|
||||
total_pages = body.dig("paging", "total_pages")
|
||||
|
||||
page += 1
|
||||
else
|
||||
raise build_error(response)
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
|
||||
13
app/models/security.rb
Normal file
13
app/models/security.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Security < ApplicationRecord
|
||||
before_save :upcase_ticker
|
||||
|
||||
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
|
||||
|
||||
validates :ticker, presence: true, uniqueness: { case_sensitive: false }
|
||||
|
||||
private
|
||||
|
||||
def upcase_ticker
|
||||
self.ticker = ticker.upcase
|
||||
end
|
||||
end
|
||||
34
app/models/security/price.rb
Normal file
34
app/models/security/price.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class Security::Price < ApplicationRecord
|
||||
include Provided
|
||||
|
||||
before_save :upcase_ticker
|
||||
|
||||
validates :ticker, presence: true, uniqueness: { scope: :date, case_sensitive: false }
|
||||
|
||||
class << self
|
||||
def find_price(ticker:, date:, cache: true)
|
||||
result = find_by(ticker:, date:)
|
||||
|
||||
result || fetch_price_from_provider(ticker:, date:, cache:)
|
||||
end
|
||||
|
||||
def find_prices(ticker:, start_date:, end_date: Date.current, cache: true)
|
||||
prices = where(ticker:, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a.to_set
|
||||
existing_dates = prices.map(&:date).to_set
|
||||
missing_dates = (all_dates - existing_dates).sort
|
||||
|
||||
if missing_dates.any?
|
||||
prices += fetch_prices_from_provider(ticker:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def upcase_ticker
|
||||
self.ticker = ticker.upcase
|
||||
end
|
||||
end
|
||||
55
app/models/security/price/provided.rb
Normal file
55
app/models/security/price/provided.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
module Security::Price::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Providable
|
||||
|
||||
class_methods do
|
||||
private
|
||||
|
||||
def fetch_price_from_provider(ticker:, date:, cache: false)
|
||||
return nil unless security_prices_provider.present?
|
||||
|
||||
response = security_prices_provider.fetch_security_prices \
|
||||
ticker: ticker,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
|
||||
if response.success? && response.prices.size > 0
|
||||
price = Security::Price.new \
|
||||
ticker: ticker,
|
||||
date: response.prices.first[:date],
|
||||
price: response.prices.first[:price],
|
||||
currency: response.prices.first[:currency]
|
||||
|
||||
price.save! if cache
|
||||
price
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_prices_from_provider(ticker:, start_date:, end_date:, cache: false)
|
||||
return [] unless security_prices_provider.present?
|
||||
|
||||
response = security_prices_provider.fetch_security_prices \
|
||||
ticker: ticker,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
|
||||
if response.success?
|
||||
response.prices.map do |price|
|
||||
new_price = Security::Price.new \
|
||||
ticker: ticker,
|
||||
date: price[:date],
|
||||
price: price[:price],
|
||||
currency: price[:currency]
|
||||
|
||||
new_price.save! if cache
|
||||
new_price
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user