Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c8e972dc8 | ||
|
|
ae9287ec9b | ||
|
|
aac9e5eca2 | ||
|
|
ca8bdb6241 | ||
|
|
1ae4b4d612 | ||
|
|
60f1a1e2d2 | ||
|
|
e1d3c7a4a1 | ||
|
|
195ec85d96 | ||
|
|
413ec6cbed | ||
|
|
e4e5ae9f25 | ||
|
|
5449fc49ef | ||
|
|
b50b7b30e8 | ||
|
|
871a68b5bc | ||
|
|
1f4c2165eb | ||
|
|
71598d26cb | ||
|
|
997d0355d4 | ||
|
|
2c30e18c9b | ||
|
|
307a3687e8 | ||
|
|
46e129308f | ||
|
|
5d1a2937bb | ||
|
|
b82b82ddf7 | ||
|
|
97852bc3b4 | ||
|
|
84d2aac1a5 | ||
|
|
49d3a9c7e7 | ||
|
|
b7019744a1 | ||
|
|
a9e791f94c | ||
|
|
cce373c31b | ||
|
|
0220861a3b | ||
|
|
fb6b6ce63d | ||
|
|
dba10c2bc8 | ||
|
|
b0d9891133 | ||
|
|
9d217afb9f | ||
|
|
77def1db40 | ||
|
|
a4d10097d5 | ||
|
|
7be6a372bf | ||
|
|
68617514b0 | ||
|
|
ba878c3d8b | ||
|
|
6034dfe5f5 | ||
|
|
ae30176816 | ||
|
|
7508ae55ac | ||
|
|
bb9fa56add | ||
|
|
54e46c1b4e | ||
|
|
0d09f2e3e9 | ||
|
|
f7ce2cdf89 | ||
|
|
f7e86d4c90 | ||
|
|
45add7512b | ||
|
|
9130089950 | ||
|
|
fe199f2357 |
250
Gemfile.lock
250
Gemfile.lock
@@ -8,29 +8,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailbox (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailer (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionpack (7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
@@ -39,35 +39,35 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actiontext (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionview (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activejob (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activemodel (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
activerecord (7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.2)
|
||||
activesupport (7.2.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -83,8 +83,8 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1018.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-partitions (1.1031.0)
|
||||
aws-sdk-core (3.214.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -92,7 +92,7 @@ GEM
|
||||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.176.0)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -108,11 +108,11 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.1.8)
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.2)
|
||||
brakeman (7.0.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -127,40 +127,40 @@ GEM
|
||||
childprocess (5.0.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
connection_pool (2.5.0)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
csv (3.3.2)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.0)
|
||||
dotenv (3.1.4)
|
||||
dotenv-rails (3.1.4)
|
||||
dotenv (= 3.1.4)
|
||||
dotenv (3.1.7)
|
||||
dotenv-rails (3.1.7)
|
||||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.7.0)
|
||||
erb_lint (0.8.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
rainbow
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.1)
|
||||
faraday (2.12.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.2.1)
|
||||
@@ -176,7 +176,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.5.1)
|
||||
good_job (4.7.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -185,10 +185,10 @@ GEM
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.1)
|
||||
highline (3.0.1)
|
||||
hotwire-livereload (1.4.1)
|
||||
actioncable (>= 6.0.0)
|
||||
hotwire-livereload (2.0.0)
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
railties (>= 7.0.0)
|
||||
hotwire_combobox (0.3.2)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
@@ -208,22 +208,23 @@ GEM
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.3)
|
||||
importmap-rails (2.1.0)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
inline_svg (1.10.0)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
intercom-rails (1.0.1)
|
||||
intercom-rails (1.0.5)
|
||||
activesupport (> 4.0)
|
||||
jwt (~> 2.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.1)
|
||||
irb (1.14.3)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.8.2)
|
||||
jwt (2.9.3)
|
||||
json (2.9.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.1)
|
||||
@@ -234,8 +235,8 @@ GEM
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.2)
|
||||
loofah (2.23.1)
|
||||
logger (1.6.5)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -247,14 +248,15 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
mocha (2.7.0)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.5.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.0)
|
||||
net-imap (0.5.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -264,37 +266,38 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.17.0-aarch64-linux)
|
||||
nokogiri (1.18.1)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-arm-linux)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-arm64-darwin)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-x86-linux)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-x86_64-darwin)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-x86_64-linux)
|
||||
nokogiri (1.18.1-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
parser (3.3.6.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
plaid (34.0.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
prism (1.2.0)
|
||||
prism (1.3.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.1)
|
||||
psych (5.2.2)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
@@ -303,42 +306,43 @@ GEM
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.8)
|
||||
rack-session (2.0.0)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.2.2)
|
||||
actioncable (= 7.2.2)
|
||||
actionmailbox (= 7.2.2)
|
||||
actionmailer (= 7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actiontext (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
rails (7.2.2.1)
|
||||
actioncable (= 7.2.2.1)
|
||||
actionmailbox (= 7.2.2.1)
|
||||
actionmailer (= 7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actiontext (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.2)
|
||||
railties (= 7.2.2.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.9)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
rails-settings-cached (2.9.5)
|
||||
rails-settings-cached (2.9.6)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
railties (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -349,26 +353,26 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.6.1)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.8.1)
|
||||
rdoc (6.10.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.12)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.9)
|
||||
rubocop (1.67.0)
|
||||
rubocop (1.70.0)
|
||||
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 (>= 2.4, < 3.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.3)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.35.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
@@ -386,13 +390,13 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.22.1)
|
||||
ruby-lsp (0.23.5)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.27)
|
||||
ruby-lsp (>= 0.22.0, < 0.23.0)
|
||||
ruby-lsp-rails (0.3.29)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
@@ -402,17 +406,17 @@ GEM
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.27.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.22.0)
|
||||
sentry-rails (5.22.1)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.0)
|
||||
sentry-ruby (5.22.0)
|
||||
sentry-ruby (~> 5.22.1)
|
||||
sentry-ruby (5.22.1)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -422,25 +426,25 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11663)
|
||||
sorbet-runtime (0.5.11751)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.2)
|
||||
stripe (13.2.0)
|
||||
tailwindcss-rails (3.0.0)
|
||||
stripe (13.3.0)
|
||||
tailwindcss-rails (3.2.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby
|
||||
tailwindcss-ruby (3.4.14)
|
||||
tailwindcss-ruby (3.4.14-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.14-arm-linux)
|
||||
tailwindcss-ruby (3.4.14-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-linux)
|
||||
tailwindcss-ruby (3.4.17)
|
||||
tailwindcss-ruby (3.4.17-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.17-arm-linux)
|
||||
tailwindcss-ruby (3.4.17-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.2)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
}
|
||||
|
||||
select.form-field__input {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@@ -51,10 +56,18 @@
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
@@ -21,24 +21,6 @@ class Account::TransactionsController < ApplicationController
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
|
||||
56
app/controllers/account/transfer_matches_controller.rb
Normal file
56
app/controllers/account/transfer_matches_controller.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Account::TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
@transfer = build_transfer
|
||||
@transfer.save!
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:transaction_id])
|
||||
end
|
||||
|
||||
def transfer_match_params
|
||||
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
if transfer_match_params[:method] == "new"
|
||||
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||
|
||||
missing_transaction = Account::Transaction.new(
|
||||
entry: target_account.entries.build(
|
||||
amount: @entry.amount * -1,
|
||||
currency: @entry.currency,
|
||||
date: @entry.date,
|
||||
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
|
||||
)
|
||||
)
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
else
|
||||
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,61 +0,0 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:alert] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update_entries!(transfer_update_params)
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
record = Account::Transfer.find(params[:id])
|
||||
|
||||
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@transfer = record
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:account_transfer).permit(:excluded, :notes)
|
||||
end
|
||||
end
|
||||
35
app/controllers/budget_categories_controller.rb
Normal file
35
app/controllers/budget_categories_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class BudgetCategoriesController < ApplicationController
|
||||
def index
|
||||
@budget = Current.family.budgets.find(params[:budget_id])
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def show
|
||||
@budget = Current.family.budgets.find(params[:budget_id])
|
||||
|
||||
@recent_transactions = @budget.entries
|
||||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
||||
else
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||
end
|
||||
|
||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
||||
end
|
||||
|
||||
def update
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@budget_category.update!(budget_category_params)
|
||||
|
||||
redirect_to budget_budget_categories_path(@budget_category.budget)
|
||||
end
|
||||
|
||||
private
|
||||
def budget_category_params
|
||||
params.require(:budget_category).permit(:budgeted_spending)
|
||||
end
|
||||
end
|
||||
55
app/controllers/budgets_controller.rb
Normal file
55
app/controllers/budgets_controller.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
class BudgetsController < ApplicationController
|
||||
before_action :set_budget, only: %i[show edit update]
|
||||
|
||||
def index
|
||||
redirect_to_current_month_budget
|
||||
end
|
||||
|
||||
def show
|
||||
@next_budget = @budget.next_budget
|
||||
@previous_budget = @budget.previous_budget
|
||||
@latest_budget = Budget.find_or_bootstrap(Current.family)
|
||||
render layout: with_sidebar
|
||||
end
|
||||
|
||||
def edit
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def update
|
||||
@budget.update!(budget_params)
|
||||
redirect_to budget_budget_categories_path(@budget)
|
||||
end
|
||||
|
||||
def create
|
||||
start_date = Date.parse(budget_create_params[:start_date])
|
||||
@budget = Budget.find_or_bootstrap(Current.family, date: start_date)
|
||||
redirect_to budget_path(@budget)
|
||||
end
|
||||
|
||||
def picker
|
||||
render partial: "budgets/picker", locals: {
|
||||
family: Current.family,
|
||||
year: params[:year].to_i || Date.current.year
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
|
||||
def budget_params
|
||||
params.require(:budget).permit(:budgeted_spending, :expected_income)
|
||||
end
|
||||
|
||||
def set_budget
|
||||
@budget = Current.family.budgets.find(params[:id])
|
||||
@budget.sync_budget_categories
|
||||
end
|
||||
|
||||
def redirect_to_current_month_budget
|
||||
current_budget = Budget.find_or_bootstrap(Current.family)
|
||||
redirect_to budget_path(current_budget)
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ class CategoriesController < ApplicationController
|
||||
|
||||
def new
|
||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -17,19 +18,28 @@ class CategoriesController < ApplicationController
|
||||
|
||||
if @category.save
|
||||
@transaction.update(category_id: @category.id) if @transaction
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
redirect_target_url = request.referer || categories_path
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to categories_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
|
||||
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
|
||||
end
|
||||
|
||||
def update
|
||||
@category.update! category_params
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -38,6 +48,12 @@ class CategoriesController < ApplicationController
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def bootstrap
|
||||
Current.family.categories.bootstrap_defaults
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
@@ -50,6 +66,6 @@ class CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(:name, :color)
|
||||
params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,11 +52,14 @@ module EntryableResource
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
)
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
@@ -119,7 +122,7 @@ module EntryableResource
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
|
||||
@@ -24,7 +24,6 @@ class RegistrationsController < ApplicationController
|
||||
|
||||
if @user.save
|
||||
@invitation&.update!(accepted_at: Time.current)
|
||||
Category.create_default_categories(@user.family) unless @invitation
|
||||
@session = create_session_for(@user)
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
|
||||
@@ -3,13 +3,18 @@ class TransactionsController < ApplicationController
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
|
||||
search_query = Current.family.transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50")
|
||||
|
||||
totals_query = search_query.incomes_and_expenses
|
||||
family_currency = Current.family.currency
|
||||
count_with_transfers = search_query.count
|
||||
count_without_transfers = totals_query.count
|
||||
|
||||
@totals = {
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
income: result.income_total(Current.family.currency).abs,
|
||||
expense: result.expense_total(Current.family.currency)
|
||||
count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers,
|
||||
income: totals_query.income_total(family_currency).abs,
|
||||
expense: totals_query.expense_total(family_currency)
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
71
app/controllers/transfers_controller.rb
Normal file
71
app/controllers/transfers_controller.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
class TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
@categories = Current.family.categories.expenses
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
)
|
||||
|
||||
if @transfer.save
|
||||
@transfer.sync_account_later
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
redirect_target_url = request.referer || transactions_path
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
Transfer.transaction do
|
||||
@transfer.update!(transfer_update_params.except(:category_id))
|
||||
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:transfer).permit(:notes, :status, :category_id)
|
||||
end
|
||||
end
|
||||
@@ -41,7 +41,7 @@ class UsersController < ApplicationController
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ module Account::EntriesHelper
|
||||
"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
|
||||
@@ -18,8 +14,19 @@ module Account::EntriesHelper
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
||||
def entry_name_detailed(entry)
|
||||
[
|
||||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
].join(" • ")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module Account::TransfersHelper
|
||||
end
|
||||
@@ -4,7 +4,7 @@ module ApplicationHelper
|
||||
def date_format_options
|
||||
[
|
||||
[ "DD-MM-YYYY", "%d-%m-%Y" ],
|
||||
[ "DD.MM.YY", "%d.%m.%Y" ],
|
||||
[ "DD.MM.YYYY", "%d.%m.%Y" ],
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
[ "YYYY-MM-DD", "%Y-%m-%d" ],
|
||||
[ "DD/MM/YYYY", "%d/%m/%Y" ],
|
||||
@@ -15,6 +15,16 @@ module ApplicationHelper
|
||||
]
|
||||
end
|
||||
|
||||
def icon(key, size: "md", color: "current")
|
||||
render partial: "shared/icon", locals: { key:, size:, color: }
|
||||
end
|
||||
|
||||
# Convert alpha (0-1) to 8-digit hex (00-FF)
|
||||
def hex_with_alpha(hex, alpha)
|
||||
alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0")
|
||||
"#{hex}#{alpha_hex}"
|
||||
end
|
||||
|
||||
def title(page_title)
|
||||
content_for(:title) { page_title }
|
||||
end
|
||||
@@ -67,9 +77,9 @@ module ApplicationHelper
|
||||
render partial: "shared/drawer", locals: { content:, reload_on_close: }
|
||||
end
|
||||
|
||||
def disclosure(title, &block)
|
||||
def disclosure(title, default_open: true, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content }
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
@@ -166,4 +176,24 @@ module ApplicationHelper
|
||||
|
||||
cookies[:admin] == "true"
|
||||
end
|
||||
|
||||
def custom_pagy_url_for(pagy, page, current_path: nil)
|
||||
if current_path.blank?
|
||||
pagy_url_for(pagy, page)
|
||||
else
|
||||
uri = URI.parse(current_path)
|
||||
params = URI.decode_www_form(uri.query || "").to_h
|
||||
|
||||
# Delete existing page param if it exists
|
||||
params.delete("page")
|
||||
# Add new page param unless it's page 1
|
||||
params["page"] = page unless page == 1
|
||||
|
||||
if params.empty?
|
||||
uri.path
|
||||
else
|
||||
"#{uri.path}?#{URI.encode_www_form(params)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
module CategoriesHelper
|
||||
def null_category
|
||||
def transfer_category
|
||||
Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Category::UNCATEGORIZED_COLOR
|
||||
name: "Transfer",
|
||||
color: Category::TRANSFER_COLOR,
|
||||
lucide_icon: "arrow-right-left"
|
||||
end
|
||||
|
||||
def payment_category
|
||||
Category.new \
|
||||
name: "Payment",
|
||||
color: Category::PAYMENT_COLOR,
|
||||
lucide_icon: "arrow-right"
|
||||
end
|
||||
|
||||
def trade_category
|
||||
Category.new \
|
||||
name: "Trade",
|
||||
color: Category::TRADE_COLOR
|
||||
end
|
||||
|
||||
def family_categories
|
||||
[ null_category ].concat(Current.family.categories.alphabetically)
|
||||
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
|
||||
end
|
||||
end
|
||||
|
||||
25
app/javascript/controllers/budget_form_controller.js
Normal file
25
app/javascript/controllers/budget_form_controller.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="budget-form"
|
||||
export default class extends Controller {
|
||||
toggleAutoFill(e) {
|
||||
const expectedIncome = e.params.income;
|
||||
const budgetedSpending = e.params.spending;
|
||||
|
||||
if (e.target.checked) {
|
||||
this.#fillField(expectedIncome.key, expectedIncome.value);
|
||||
this.#fillField(budgetedSpending.key, budgetedSpending.value);
|
||||
} else {
|
||||
this.#clearField(expectedIncome.key);
|
||||
this.#clearField(budgetedSpending.key);
|
||||
}
|
||||
}
|
||||
|
||||
#fillField(id, value) {
|
||||
this.element.querySelector(`input[id="${id}"]`).value = value;
|
||||
}
|
||||
|
||||
#clearField(id) {
|
||||
this.element.querySelector(`input[id="${id}"]`).value = "";
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
_rowsForGroup(group) {
|
||||
return this.rowTargets.filter((row) => group.contains(row));
|
||||
return this.rowTargets.filter(
|
||||
(row) => group.contains(row) && !row.disabled,
|
||||
);
|
||||
}
|
||||
|
||||
_addToSelection(idToAdd) {
|
||||
@@ -115,7 +117,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
_selectAll() {
|
||||
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id);
|
||||
this.selectedIdsValue = this.rowTargets
|
||||
.filter((t) => !t.disabled)
|
||||
.map((t) => t.dataset.id);
|
||||
}
|
||||
|
||||
_updateView = () => {
|
||||
|
||||
168
app/javascript/controllers/donut_chart_controller.js
Normal file
168
app/javascript/controllers/donut_chart_controller.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="donut-chart"
|
||||
export default class extends Controller {
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent"];
|
||||
static values = {
|
||||
segments: { type: Array, default: [] },
|
||||
unusedSegmentId: { type: String, default: "unused" },
|
||||
overageSegmentId: { type: String, default: "overage" },
|
||||
segmentHeight: { type: Number, default: 3 },
|
||||
segmentOpacity: { type: Number, default: 1 },
|
||||
};
|
||||
|
||||
#viewBoxSize = 100;
|
||||
#minSegmentAngle = this.segmentHeightValue * 0.01;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#teardown();
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
}
|
||||
|
||||
get #data() {
|
||||
const totalPieValue = this.segmentsValue.reduce(
|
||||
(acc, s) => acc + Number(s.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
// Overage is always first segment, unused is always last segment
|
||||
return this.segmentsValue
|
||||
.filter((s) => s.amount > 0)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
amount: Math.max(
|
||||
Number(s.amount),
|
||||
totalPieValue * this.#minSegmentAngle,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.id === this.overageSegmentIdValue) return -1;
|
||||
if (b.id === this.overageSegmentIdValue) return 1;
|
||||
if (a.id === this.unusedSegmentIdValue) return 1;
|
||||
if (b.id === this.unusedSegmentIdValue) return -1;
|
||||
return b.amount - a.amount;
|
||||
});
|
||||
}
|
||||
|
||||
#redraw = () => {
|
||||
this.#teardown();
|
||||
this.#draw();
|
||||
};
|
||||
|
||||
#teardown() {
|
||||
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const svg = d3
|
||||
.select(this.chartContainerTarget)
|
||||
.append("svg")
|
||||
.attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio
|
||||
.attr("preserveAspectRatio", "xMidYMid meet")
|
||||
.attr("class", "w-full h-full");
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
.sortValues(null) // Preserve order of segments
|
||||
.value((d) => d.amount);
|
||||
|
||||
const mainArc = d3
|
||||
.arc()
|
||||
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)
|
||||
.outerRadius(this.#viewBoxSize / 2)
|
||||
.cornerRadius(this.segmentHeightValue)
|
||||
.padAngle(this.#minSegmentAngle);
|
||||
|
||||
const segmentArcs = svg
|
||||
.append("g")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
|
||||
)
|
||||
.selectAll("arc")
|
||||
.data(pie(this.#data))
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "arc pointer-events-auto")
|
||||
.append("path")
|
||||
.attr("data-segment-id", (d) => d.data.id)
|
||||
.attr("data-original-color", this.#transformRingColor)
|
||||
.attr("fill", this.#transformRingColor)
|
||||
.attr("d", mainArc);
|
||||
|
||||
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
|
||||
let hoverTimeout = null;
|
||||
|
||||
segmentArcs
|
||||
.on("mouseover", (event) => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
this.#clearSegmentHover();
|
||||
this.#handleSegmentHover(event);
|
||||
}, 150);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
clearTimeout(hoverTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
#transformRingColor = ({ data: { id, color } }) => {
|
||||
if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const reducedOpacityColor = d3.color(color);
|
||||
reducedOpacityColor.opacity = this.segmentOpacityValue;
|
||||
return reducedOpacityColor;
|
||||
};
|
||||
|
||||
// Highlights segment and shows segment specific content (all other segments are grayed out)
|
||||
#handleSegmentHover(event) {
|
||||
const segmentId = event.target.dataset.segmentId;
|
||||
const template = this.element.querySelector(`#segment_${segmentId}`);
|
||||
const unusedSegmentId = this.unusedSegmentIdValue;
|
||||
|
||||
if (!template) return;
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
if (this.dataset.segmentId === segmentId) {
|
||||
if (this.dataset.segmentId === unusedSegmentId) {
|
||||
return "#A3A3A3";
|
||||
}
|
||||
|
||||
return this.dataset.originalColor;
|
||||
}
|
||||
|
||||
return "#F0F0F0";
|
||||
});
|
||||
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
template.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Restores original segment colors and hides segment specific content
|
||||
#clearSegmentHover = () => {
|
||||
this.defaultContentTarget.classList.remove("hidden");
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
return this.dataset.originalColor;
|
||||
});
|
||||
|
||||
for (const child of this.contentContainerTarget.children) {
|
||||
if (child !== this.defaultContentTarget) {
|
||||
child.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
16
app/javascript/controllers/transfer_match_controller.js
Normal file
16
app/javascript/controllers/transfer_match_controller.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="transfer-match"
|
||||
export default class extends Controller {
|
||||
static targets = ["newSelect", "existingSelect"];
|
||||
|
||||
update(event) {
|
||||
if (event.target.value === "new") {
|
||||
this.newSelectTarget.classList.remove("hidden");
|
||||
this.existingSelectTarget.classList.add("hidden");
|
||||
} else {
|
||||
this.newSelectTarget.classList.add("hidden");
|
||||
this.existingSelectTarget.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/jobs/enrich_data_job.rb
Normal file
7
app/jobs/enrich_data_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class EnrichDataJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account)
|
||||
account.enrich_data
|
||||
end
|
||||
end
|
||||
@@ -126,6 +126,14 @@ class Account < ApplicationRecord
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def enrich_data
|
||||
DataEnricher.new(self).run
|
||||
end
|
||||
|
||||
def enrich_data_later
|
||||
EnrichDataJob.perform_later(self)
|
||||
end
|
||||
|
||||
def update_with_sync!(attributes)
|
||||
transaction do
|
||||
update!(attributes)
|
||||
@@ -143,6 +151,7 @@ class Account < ApplicationRecord
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
56
app/models/account/data_enricher.rb
Normal file
56
app/models/account/data_enricher.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Account::DataEnricher
|
||||
include Providable
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def run
|
||||
enrich_transactions
|
||||
end
|
||||
|
||||
private
|
||||
def enrich_transactions
|
||||
candidates = account.entries.account_transactions.includes(entryable: [ :merchant, :category ])
|
||||
|
||||
Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}")
|
||||
|
||||
merchants = {}
|
||||
|
||||
candidates.each do |entry|
|
||||
if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil?
|
||||
begin
|
||||
next unless entry.name.present?
|
||||
|
||||
info = self.class.synth_provider.enrich_transaction(entry.name).info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
entryable_attributes = { id: entry.entryable_id }
|
||||
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
entryable_attributes: entryable_attributes
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,14 +10,14 @@ class Account::Entry < ApplicationRecord
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, :name, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
scope :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
@@ -25,12 +25,29 @@ class Account::Entry < ApplicationRecord
|
||||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
|
||||
scope :incomes_and_expenses, -> {
|
||||
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
|
||||
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
|
||||
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
|
||||
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
|
||||
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
|
||||
}
|
||||
|
||||
scope :incomes, -> {
|
||||
incomes_and_expenses.where("account_entries.amount <= 0")
|
||||
}
|
||||
|
||||
scope :expenses, -> {
|
||||
incomes_and_expenses.where("account_entries.amount > 0")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -47,14 +64,6 @@ class Account::Entry < ApplicationRecord
|
||||
account.sync_later(start_date: sync_start_date)
|
||||
end
|
||||
|
||||
def inflow?
|
||||
amount <= 0 && account_transaction?
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0 && account_transaction?
|
||||
end
|
||||
|
||||
def entryable_name_short
|
||||
entryable_type.demodulize.underscore
|
||||
end
|
||||
@@ -63,7 +72,24 @@ class Account::Entry < ApplicationRecord
|
||||
Account::BalanceTrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
def display_name
|
||||
enriched_name.presence || name
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
account.family.entries
|
||||
.where.not(account_id: account_id)
|
||||
.where.not(id: id)
|
||||
.where(amount: -amount)
|
||||
.where(currency: currency)
|
||||
.where(date: (date - 4.days)..(date + 4.days))
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
Account::EntrySearch.new(params).build_query(all)
|
||||
end
|
||||
|
||||
# arbitrary cutoff date to avoid expensive sync operations
|
||||
def min_supported_date
|
||||
30.years.ago.to_date
|
||||
@@ -98,13 +124,6 @@ class Account::Entry < ApplicationRecord
|
||||
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],
|
||||
@@ -127,81 +146,20 @@ class Account::Entry < ApplicationRecord
|
||||
all.size
|
||||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount <= 0")
|
||||
def income_total(currency = "USD", start_date: nil, end_date: nil)
|
||||
total = incomes.where(date: start_date..end_date)
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
Money.new(total, currency)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount > 0")
|
||||
def expense_total(currency = "USD", start_date: nil, end_date: nil)
|
||||
total = expenses.where(date: start_date..end_date)
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
Money.new(total, currency)
|
||||
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[:types].present?
|
||||
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
|
||||
|
||||
if params[:types].include?("income") && !params[:types].include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif params[:types].include?("expense") && !params[:types].include?("income")
|
||||
query = query.where("account_entries.amount >= 0")
|
||||
end
|
||||
end
|
||||
|
||||
if params[:amount].present? && params[:amount_operator].present?
|
||||
case params[:amount_operator]
|
||||
when "equal"
|
||||
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
|
||||
when "less"
|
||||
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
|
||||
when "greater"
|
||||
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
|
||||
end
|
||||
end
|
||||
|
||||
if params[:accounts].present? || params[:account_ids].present?
|
||||
query = query.joins(:account)
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
57
app/models/account/entry_search.rb
Normal file
57
app/models/account/entry_search.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class Account::EntrySearch
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
attribute :search, :string
|
||||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, :string
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
attribute :end_date, :string
|
||||
|
||||
class << self
|
||||
def from_entryable_search(entryable_search)
|
||||
new(entryable_search.attributes.slice(*attribute_names))
|
||||
end
|
||||
end
|
||||
|
||||
def build_query(scope)
|
||||
query = scope
|
||||
|
||||
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
|
||||
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
|
||||
) if search.present?
|
||||
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
|
||||
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
|
||||
|
||||
if types.present?
|
||||
if types.include?("income") && !types.include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif types.include?("expense") && !types.include?("income")
|
||||
query = query.where("account_entries.amount >= 0")
|
||||
end
|
||||
end
|
||||
|
||||
if amount.present? && amount_operator.present?
|
||||
case amount_operator
|
||||
when "equal"
|
||||
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
|
||||
when "less"
|
||||
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
|
||||
when "greater"
|
||||
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
|
||||
end
|
||||
end
|
||||
|
||||
if accounts.present? || account_ids.present?
|
||||
query = query.joins(:account)
|
||||
end
|
||||
|
||||
query = query.where(accounts: { name: accounts }) if accounts.present?
|
||||
query = query.where(accounts: { id: account_ids }) if account_ids.present?
|
||||
|
||||
query
|
||||
end
|
||||
end
|
||||
@@ -98,7 +98,7 @@ class Account::HoldingCalculator
|
||||
end
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.to_a
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
end
|
||||
|
||||
def portfolio_start_date
|
||||
|
||||
@@ -5,11 +5,20 @@ class Account::Syncer
|
||||
end
|
||||
|
||||
def run
|
||||
Transfer.auto_match_for_account(account)
|
||||
|
||||
holdings = sync_holdings
|
||||
balances = sync_balances(holdings)
|
||||
account.reload
|
||||
update_account_info(balances, holdings) unless account.plaid_account_id.present?
|
||||
convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency
|
||||
|
||||
# Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
|
||||
if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?)
|
||||
account.enrich_data_later
|
||||
else
|
||||
Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -8,31 +8,8 @@ class Account::Trade < ApplicationRecord
|
||||
validates :qty, presence: true
|
||||
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
|
||||
|
||||
def name
|
||||
prefix = sell? ? "Sell " : "Buy "
|
||||
prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||
end
|
||||
|
||||
def unrealized_gain_loss
|
||||
return nil if sell?
|
||||
return nil if qty.negative?
|
||||
current_price = security.current_price
|
||||
return nil if current_price.nil?
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ class Account::TradeBuilder
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
@@ -17,7 +24,7 @@ class Account::TradeBuilder
|
||||
end
|
||||
|
||||
private
|
||||
def buildable
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
@@ -31,7 +38,11 @@ class Account::TradeBuilder
|
||||
end
|
||||
|
||||
def build_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
|
||||
account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
@@ -51,9 +62,9 @@ class Account::TradeBuilder
|
||||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Account::Transfer.build_from_accounts(
|
||||
from_account,
|
||||
to_account,
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
@@ -63,7 +74,6 @@ class Account::TradeBuilder
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
@@ -6,62 +6,24 @@ class Account::Transaction < ApplicationRecord
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :restrict_with_exception
|
||||
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :restrict_with_exception
|
||||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
scope :active, -> { where(excluded: false) }
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
query = all
|
||||
if params[:categories].present?
|
||||
if params[:categories].exclude?("Uncategorized")
|
||||
query = query
|
||||
.joins(:category)
|
||||
.where(categories: { name: params[:categories] })
|
||||
else
|
||||
query = query
|
||||
.left_joins(:category)
|
||||
.where(categories: { name: params[:categories] })
|
||||
.or(query.where(category_id: nil))
|
||||
end
|
||||
end
|
||||
|
||||
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
|
||||
if params[:tags].present?
|
||||
query = query.joins(:tags)
|
||||
.where(tags: { name: params[:tags] })
|
||||
.distinct
|
||||
end
|
||||
|
||||
query
|
||||
Account::TransactionSearch.new(params).build_query(all)
|
||||
end
|
||||
|
||||
def requires_search?(params)
|
||||
searchable_keys.any? { |key| params.key?(key) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[categories merchants tags]
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
entry.name || "(no description)"
|
||||
def transfer
|
||||
transfer_as_inflow || transfer_as_outflow
|
||||
end
|
||||
|
||||
def eod_balance
|
||||
entry.amount_money
|
||||
def transfer?
|
||||
transfer.present? && transfer.status != "rejected"
|
||||
end
|
||||
|
||||
private
|
||||
def account
|
||||
entry.account
|
||||
end
|
||||
|
||||
def daily_transactions
|
||||
account.entries.account_transactions
|
||||
end
|
||||
end
|
||||
|
||||
47
app/models/account/transaction_search.rb
Normal file
47
app/models/account/transaction_search.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Account::TransactionSearch
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
attribute :search, :string
|
||||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, array: true
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
attribute :end_date, :string
|
||||
attribute :categories, array: true
|
||||
attribute :merchants, array: true
|
||||
attribute :tags, array: true
|
||||
|
||||
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
|
||||
def build_query(scope)
|
||||
query = scope
|
||||
|
||||
if types.present? && types.exclude?("transfer")
|
||||
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
|
||||
.where("transfers.id IS NULL")
|
||||
end
|
||||
|
||||
if categories.present?
|
||||
if categories.exclude?("Uncategorized")
|
||||
query = query
|
||||
.joins(:category)
|
||||
.where(categories: { name: categories })
|
||||
else
|
||||
query = query
|
||||
.left_joins(:category)
|
||||
.where(categories: { name: categories })
|
||||
.or(query.where(category_id: nil))
|
||||
end
|
||||
end
|
||||
|
||||
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
|
||||
|
||||
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
|
||||
|
||||
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
|
||||
|
||||
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
|
||||
end
|
||||
end
|
||||
@@ -1,113 +0,0 @@
|
||||
class Account::Transfer < ApplicationRecord
|
||||
has_many :entries, dependent: :destroy
|
||||
|
||||
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
|
||||
from_account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
to_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 from_account
|
||||
outflow_transaction&.account
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction&.account
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
entries.find { |e| e.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
entries.find { |e| e.outflow? }
|
||||
end
|
||||
|
||||
def update_entries!(params)
|
||||
transaction do
|
||||
entries.each do |entry|
|
||||
entry.update!(params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
entries.each(&:sync_account_later)
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
||||
# use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: converted_amount.amount * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new entries: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
entries.map { |e| e.currency }.uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless entries.size == 2
|
||||
errors.add :entries, :must_have_exactly_2_entries
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = entries.map { |e| e.account_id }.uniq
|
||||
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless entries.sum(&:amount).zero?
|
||||
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, :must_be_marked_as_transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,3 @@
|
||||
class Account::Valuation < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
182
app/models/budget.rb
Normal file
182
app/models/budget.rb
Normal file
@@ -0,0 +1,182 @@
|
||||
class Budget < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :family
|
||||
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
|
||||
validates :start_date, :end_date, presence: true
|
||||
validates :start_date, :end_date, uniqueness: { scope: :family_id }
|
||||
|
||||
monetize :budgeted_spending, :expected_income, :allocated_spending,
|
||||
:actual_spending, :available_to_spend, :available_to_allocate,
|
||||
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
|
||||
|
||||
class << self
|
||||
def for_date(date)
|
||||
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
|
||||
end
|
||||
|
||||
def find_or_bootstrap(family, date: Date.current)
|
||||
Budget.transaction do
|
||||
budget = Budget.find_or_create_by!(
|
||||
family: family,
|
||||
start_date: date.beginning_of_month,
|
||||
end_date: date.end_of_month
|
||||
) do |b|
|
||||
b.currency = family.currency
|
||||
end
|
||||
|
||||
budget.sync_budget_categories
|
||||
|
||||
budget
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_budget_categories
|
||||
family.categories.expenses.each do |category|
|
||||
budget_categories.find_or_create_by(
|
||||
category: category,
|
||||
) do |bc|
|
||||
bc.budgeted_spending = 0
|
||||
bc.currency = family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def uncategorized_budget_category
|
||||
budget_categories.uncategorized.tap do |bc|
|
||||
bc.budgeted_spending = [ available_to_allocate, 0 ].max
|
||||
bc.currency = family.currency
|
||||
end
|
||||
end
|
||||
|
||||
def entries
|
||||
family.entries.incomes_and_expenses.where(date: start_date..end_date)
|
||||
end
|
||||
|
||||
def name
|
||||
start_date.strftime("%B %Y")
|
||||
end
|
||||
|
||||
def initialized?
|
||||
budgeted_spending.present?
|
||||
end
|
||||
|
||||
def income_categories_with_totals
|
||||
family.income_categories_with_totals(date: start_date)
|
||||
end
|
||||
|
||||
def expense_categories_with_totals
|
||||
family.expense_categories_with_totals(date: start_date)
|
||||
end
|
||||
|
||||
def current?
|
||||
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
|
||||
end
|
||||
|
||||
def previous_budget
|
||||
prev_month_end_date = end_date - 1.month
|
||||
return nil if prev_month_end_date < family.oldest_entry_date
|
||||
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
|
||||
end
|
||||
|
||||
def next_budget
|
||||
return nil if current?
|
||||
next_start_date = start_date + 1.month
|
||||
family.budgets.find_or_bootstrap(family, date: next_start_date)
|
||||
end
|
||||
|
||||
def to_donut_segments_json
|
||||
unused_segment_id = "unused"
|
||||
|
||||
# Continuous gray segment for empty budgets
|
||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
||||
|
||||
segments = budget_categories.map do |bc|
|
||||
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
|
||||
end
|
||||
|
||||
if available_to_spend.positive?
|
||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
||||
end
|
||||
|
||||
segments
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Actuals: How much user has spent on each budget category
|
||||
# =============================================================================
|
||||
def estimated_spending
|
||||
family.budgeting_stats.avg_monthly_expenses&.abs
|
||||
end
|
||||
|
||||
def actual_spending
|
||||
budget_categories.reject(&:subcategory?).sum(&:actual_spending)
|
||||
end
|
||||
|
||||
def available_to_spend
|
||||
(budgeted_spending || 0) - actual_spending
|
||||
end
|
||||
|
||||
def percent_of_budget_spent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(actual_spending / budgeted_spending.to_f) * 100
|
||||
end
|
||||
|
||||
def overage_percent
|
||||
return 0 unless available_to_spend.negative?
|
||||
|
||||
available_to_spend.abs / actual_spending.to_f * 100
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Budget allocations: How much user has budgeted for all categories combined
|
||||
# =============================================================================
|
||||
def allocated_spending
|
||||
budget_categories.sum(:budgeted_spending)
|
||||
end
|
||||
|
||||
def allocated_percent
|
||||
return 0 unless budgeted_spending && budgeted_spending > 0
|
||||
|
||||
(allocated_spending / budgeted_spending.to_f) * 100
|
||||
end
|
||||
|
||||
def available_to_allocate
|
||||
(budgeted_spending || 0) - allocated_spending
|
||||
end
|
||||
|
||||
def allocations_valid?
|
||||
initialized? && available_to_allocate.positive? && allocated_spending > 0
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Income: How much user earned relative to what they expected to earn
|
||||
# =============================================================================
|
||||
def estimated_income
|
||||
family.budgeting_stats.avg_monthly_income&.abs
|
||||
end
|
||||
|
||||
def actual_income
|
||||
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
|
||||
end
|
||||
|
||||
def actual_income_percent
|
||||
return 0 unless expected_income > 0
|
||||
|
||||
(actual_income / expected_income.to_f) * 100
|
||||
end
|
||||
|
||||
def remaining_expected_income
|
||||
expected_income - actual_income
|
||||
end
|
||||
|
||||
def surplus_percent
|
||||
return 0 unless remaining_expected_income.negative?
|
||||
|
||||
remaining_expected_income.abs / expected_income.to_f * 100
|
||||
end
|
||||
end
|
||||
82
app/models/budget_category.rb
Normal file
82
app/models/budget_category.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
class BudgetCategory < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :budget
|
||||
belongs_to :category
|
||||
|
||||
validates :budget_id, uniqueness: { scope: :category_id }
|
||||
|
||||
monetize :budgeted_spending, :actual_spending, :available_to_spend
|
||||
|
||||
class Group
|
||||
attr_reader :budget_category, :budget_subcategories
|
||||
|
||||
delegate :category, to: :budget_category
|
||||
delegate :name, :color, to: :category
|
||||
|
||||
def self.for(budget_categories)
|
||||
top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }
|
||||
top_level_categories.map do |top_level_category|
|
||||
subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }
|
||||
new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })
|
||||
end.sort_by { |group| group.category.name }
|
||||
end
|
||||
|
||||
def initialize(budget_category, budget_subcategories = [])
|
||||
@budget_category = budget_category
|
||||
@budget_subcategories = budget_subcategories
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def uncategorized
|
||||
new(
|
||||
id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"),
|
||||
category: nil,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialized?
|
||||
budget.initialized?
|
||||
end
|
||||
|
||||
def category
|
||||
super || budget.family.categories.uncategorized
|
||||
end
|
||||
|
||||
def subcategory?
|
||||
category.parent_id.present?
|
||||
end
|
||||
|
||||
def actual_spending
|
||||
category.month_total(date: budget.start_date)
|
||||
end
|
||||
|
||||
def available_to_spend
|
||||
(budgeted_spending || 0) - actual_spending
|
||||
end
|
||||
|
||||
def percent_of_budget_spent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(actual_spending / budgeted_spending) * 100
|
||||
end
|
||||
|
||||
def to_donut_segments_json
|
||||
unused_segment_id = "unused"
|
||||
overage_segment_id = "overage"
|
||||
|
||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
|
||||
|
||||
segments = [ { color: category.color, amount: actual_spending, id: id } ]
|
||||
|
||||
if available_to_spend.negative?
|
||||
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
|
||||
else
|
||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
||||
end
|
||||
|
||||
segments
|
||||
end
|
||||
end
|
||||
29
app/models/budgeting_stats.rb
Normal file
29
app/models/budgeting_stats.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class BudgetingStats
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def avg_monthly_income
|
||||
income_expense_totals_query(Account::Entry.incomes)
|
||||
end
|
||||
|
||||
def avg_monthly_expenses
|
||||
income_expense_totals_query(Account::Entry.expenses)
|
||||
end
|
||||
|
||||
private
|
||||
def income_expense_totals_query(type_scope)
|
||||
monthly_totals = family.entries
|
||||
.merge(type_scope)
|
||||
.select("SUM(account_entries.amount) as total")
|
||||
.group(Arel.sql("date_trunc('month', account_entries.date)"))
|
||||
|
||||
result = Family.select("AVG(mt.total)")
|
||||
.from(monthly_totals, :mt)
|
||||
.pick("AVG(mt.total)")
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
@@ -1,43 +1,87 @@
|
||||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
|
||||
belongs_to :family
|
||||
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
|
||||
belongs_to :parent, class_name: "Category", optional: true
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family_id }
|
||||
|
||||
before_update :clear_internal_category, if: :name_changed?
|
||||
validate :category_level_limit
|
||||
validate :nested_category_matches_parent_classification
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :incomes, -> { where(classification: "income") }
|
||||
scope :expenses, -> { where(classification: "expense") }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
TRANSFER_COLOR = "#444CE7"
|
||||
PAYMENT_COLOR = "#db5a54"
|
||||
TRADE_COLOR = "#e99537"
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
{ internal_category: "income", color: COLORS[0] },
|
||||
{ internal_category: "food_and_drink", color: COLORS[1] },
|
||||
{ internal_category: "entertainment", color: COLORS[2] },
|
||||
{ internal_category: "personal_care", color: COLORS[3] },
|
||||
{ internal_category: "general_services", color: COLORS[4] },
|
||||
{ internal_category: "auto_and_transport", color: COLORS[5] },
|
||||
{ internal_category: "rent_and_utilities", color: COLORS[6] },
|
||||
{ internal_category: "home_improvement", color: COLORS[7] }
|
||||
]
|
||||
class Group
|
||||
attr_reader :category, :subcategories
|
||||
|
||||
def self.create_default_categories(family)
|
||||
if family.categories.size > 0
|
||||
raise ArgumentError, "Family already has some categories"
|
||||
delegate :name, :color, to: :category
|
||||
|
||||
def self.for(categories)
|
||||
categories.select { |category| category.parent_id.nil? }.map do |category|
|
||||
new(category, category.subcategories)
|
||||
end
|
||||
end
|
||||
|
||||
family_id = family.id
|
||||
categories = self::DEFAULT_CATEGORIES.map { |c| {
|
||||
name: I18n.t("transaction.default_category.#{c[:internal_category]}"),
|
||||
internal_category: c[:internal_category],
|
||||
color: c[:color],
|
||||
family_id:
|
||||
} }
|
||||
self.insert_all(categories)
|
||||
def initialize(category, subcategories = nil)
|
||||
@category = category
|
||||
@subcategories = subcategories || []
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def icon_codes
|
||||
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
|
||||
end
|
||||
|
||||
def bootstrap_defaults
|
||||
default_categories.each do |name, color, icon|
|
||||
find_or_create_by!(name: name) do |category|
|
||||
category.color = color
|
||||
category.classification = "income" if name == "Income"
|
||||
category.lucide_icon = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def uncategorized
|
||||
new(
|
||||
name: "Uncategorized",
|
||||
color: UNCATEGORIZED_COLOR,
|
||||
lucide_icon: "circle-dashed"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def default_categories
|
||||
[
|
||||
[ "Income", "#e99537", "circle-dollar-sign" ],
|
||||
[ "Housing", "#6471eb", "house" ],
|
||||
[ "Entertainment", "#df4e92", "drama" ],
|
||||
[ "Food & Drink", "#eb5429", "utensils" ],
|
||||
[ "Shopping", "#e99537", "shopping-cart" ],
|
||||
[ "Healthcare", "#4da568", "pill" ],
|
||||
[ "Insurance", "#6471eb", "piggy-bank" ],
|
||||
[ "Utilities", "#db5a54", "lightbulb" ],
|
||||
[ "Transportation", "#df4e92", "bus" ],
|
||||
[ "Education", "#eb5429", "book" ],
|
||||
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
|
||||
[ "Subscriptions", "#805dee", "credit-card" ]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def replace_and_destroy!(replacement)
|
||||
@@ -47,9 +91,32 @@ class Category < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def subcategory?
|
||||
parent.present?
|
||||
end
|
||||
|
||||
def clear_internal_category
|
||||
self.internal_category = nil
|
||||
def avg_monthly_total
|
||||
family.category_stats.avg_monthly_total_for(self)
|
||||
end
|
||||
|
||||
def median_monthly_total
|
||||
family.category_stats.median_monthly_total_for(self)
|
||||
end
|
||||
|
||||
def month_total(date: Date.current)
|
||||
family.category_stats.month_total_for(self, date: date)
|
||||
end
|
||||
|
||||
private
|
||||
def category_level_limit
|
||||
if subcategory? && parent.subcategory?
|
||||
errors.add(:parent, "can't have more than 2 levels of subcategories")
|
||||
end
|
||||
end
|
||||
|
||||
def nested_category_matches_parent_classification
|
||||
if subcategory? && parent.classification != classification
|
||||
errors.add(:parent, "must have the same classification as its parent")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
179
app/models/category_stats.rb
Normal file
179
app/models/category_stats.rb
Normal file
@@ -0,0 +1,179 @@
|
||||
class CategoryStats
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def avg_monthly_total_for(category)
|
||||
statistics_data[category.id]&.avg || 0
|
||||
end
|
||||
|
||||
def median_monthly_total_for(category)
|
||||
statistics_data[category.id]&.median || 0
|
||||
end
|
||||
|
||||
def month_total_for(category, date: Date.current)
|
||||
monthly_totals = totals_data[category.id]
|
||||
|
||||
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
|
||||
|
||||
category_total&.amount || 0
|
||||
end
|
||||
|
||||
def month_category_totals(date: Date.current)
|
||||
by_classification = Hash.new { |h, k| h[k] = {} }
|
||||
|
||||
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
|
||||
totals.each do |t|
|
||||
next unless t.month == date.month && t.year == date.year
|
||||
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
|
||||
result[t.classification][category_id][:amount] += t.amount.abs
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate percentages for each group
|
||||
category_totals = []
|
||||
|
||||
[ "income", "expense" ].each do |classification|
|
||||
totals = by_classification[classification]
|
||||
|
||||
# Only include non-subcategory amounts in the total for percentage calculations
|
||||
total_amount = totals.sum do |_, data|
|
||||
data[:subcategory] ? 0 : data[:amount]
|
||||
end
|
||||
|
||||
next if total_amount.zero?
|
||||
|
||||
totals.each do |category_id, data|
|
||||
percentage = (data[:amount].to_f / total_amount * 100).round(1)
|
||||
|
||||
category_totals << CategoryTotal.new(
|
||||
category_id: category_id,
|
||||
amount: data[:amount],
|
||||
percentage: percentage,
|
||||
classification: classification,
|
||||
currency: family.currency,
|
||||
subcategory?: data[:subcategory]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate totals based on non-subcategory amounts only
|
||||
total_income = category_totals
|
||||
.select { |ct| ct.classification == "income" && !ct.subcategory? }
|
||||
.sum(&:amount)
|
||||
|
||||
total_expense = category_totals
|
||||
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
|
||||
.sum(&:amount)
|
||||
|
||||
CategoryTotals.new(
|
||||
total_income: total_income,
|
||||
total_expense: total_expense,
|
||||
category_totals: category_totals
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
|
||||
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
|
||||
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
|
||||
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
|
||||
|
||||
def statistics_data
|
||||
@statistics_data ||= begin
|
||||
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
|
||||
next if totals.empty?
|
||||
|
||||
amounts = totals.map(&:amount)
|
||||
hash[category_id] = Stats.new(
|
||||
avg: (amounts.sum.to_f / amounts.size).round,
|
||||
median: calculate_median(amounts),
|
||||
currency: family.currency
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def totals_data
|
||||
@totals_data ||= begin
|
||||
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
|
||||
hash[row.category_id] ||= []
|
||||
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
|
||||
|
||||
if existing_total
|
||||
existing_total.amount += row.total.to_i
|
||||
else
|
||||
hash[row.category_id] << Totals.new(
|
||||
month: row.date.month,
|
||||
year: row.date.year,
|
||||
amount: row.total.to_i,
|
||||
classification: row.classification,
|
||||
currency: family.currency,
|
||||
subcategory?: row.parent_category_id.present?
|
||||
)
|
||||
end
|
||||
|
||||
# If category is a parent, its total includes its own transactions + sum(child category transactions)
|
||||
if row.parent_category_id
|
||||
hash[row.parent_category_id] ||= []
|
||||
|
||||
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
|
||||
|
||||
if existing_parent_total
|
||||
existing_parent_total.amount += row.total.to_i
|
||||
else
|
||||
hash[row.parent_category_id] << Totals.new(
|
||||
month: row.date.month,
|
||||
year: row.date.year,
|
||||
amount: row.total.to_i,
|
||||
classification: row.classification,
|
||||
currency: family.currency,
|
||||
subcategory?: false
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
|
||||
totals[nil] ||= []
|
||||
totals
|
||||
end
|
||||
end
|
||||
|
||||
def monthly_totals_query
|
||||
income_expense_classification = Arel.sql("
|
||||
CASE WHEN categories.id IS NULL THEN
|
||||
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
ELSE categories.classification
|
||||
END
|
||||
")
|
||||
|
||||
family.entries
|
||||
.incomes_and_expenses
|
||||
.select(
|
||||
"categories.id as category_id",
|
||||
"categories.parent_id as parent_category_id",
|
||||
income_expense_classification,
|
||||
"date_trunc('month', account_entries.date) as date",
|
||||
"SUM(account_entries.amount) as total"
|
||||
)
|
||||
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
|
||||
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
|
||||
end
|
||||
|
||||
|
||||
def calculate_median(numbers)
|
||||
return 0 if numbers.empty?
|
||||
|
||||
sorted = numbers.sort
|
||||
mid = sorted.size / 2
|
||||
if sorted.size.odd?
|
||||
sorted[mid]
|
||||
else
|
||||
((sorted[mid-1] + sorted[mid]) / 2.0).round
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,6 +36,8 @@ class Demo::Generator
|
||||
create_car_and_loan!
|
||||
create_other_accounts!
|
||||
|
||||
create_transfer_transactions!
|
||||
|
||||
puts "accounts created"
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
@@ -49,12 +51,14 @@ class Demo::Generator
|
||||
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
|
||||
|
||||
family = Family.find_by(id: family_id)
|
||||
Transfer.destroy_all
|
||||
family.destroy! if family
|
||||
|
||||
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
|
||||
end
|
||||
|
||||
def clear_data!
|
||||
Transfer.destroy_all
|
||||
InviteCode.destroy_all
|
||||
User.find_by_email("user@maybe.local")&.destroy
|
||||
ExchangeRate.destroy_all
|
||||
@@ -83,13 +87,12 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def create_categories!
|
||||
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
|
||||
"Personal Care", "General Services", "Auto & Transport",
|
||||
"Rent & Utilities", "Home Improvement", "Shopping" ]
|
||||
family.categories.bootstrap_defaults
|
||||
|
||||
categories.each do |category|
|
||||
family.categories.create!(name: category, color: COLORS.sample)
|
||||
end
|
||||
food = family.categories.find_by(name: "Food & Drink")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
|
||||
end
|
||||
|
||||
def create_merchants!
|
||||
@@ -172,6 +175,40 @@ class Demo::Generator
|
||||
end
|
||||
end
|
||||
|
||||
def create_transfer_transactions!
|
||||
checking = family.accounts.find_by(name: "Chase Checking")
|
||||
credit_card = family.accounts.find_by(name: "Chase Credit Card")
|
||||
investment = family.accounts.find_by(name: "Robinhood")
|
||||
|
||||
create_transaction!(
|
||||
account: checking,
|
||||
date: 1.day.ago.to_date,
|
||||
amount: 100,
|
||||
name: "Credit Card Payment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: credit_card,
|
||||
date: 1.day.ago.to_date,
|
||||
amount: -100,
|
||||
name: "Credit Card Payment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: checking,
|
||||
date: 3.days.ago.to_date,
|
||||
amount: 500,
|
||||
name: "Transfer to investment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: investment,
|
||||
date: 2.days.ago.to_date,
|
||||
amount: -500,
|
||||
name: "Transfer from checking"
|
||||
)
|
||||
end
|
||||
|
||||
def load_securities!
|
||||
# Create an unknown security to simulate edge cases
|
||||
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN"
|
||||
@@ -303,6 +340,7 @@ class Demo::Generator
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
name: "Balance update",
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
@@ -318,17 +356,17 @@ class Demo::Generator
|
||||
"McDonald's" => "Food & Drink",
|
||||
"Target" => "Shopping",
|
||||
"Costco" => "Food & Drink",
|
||||
"Home Depot" => "Home Improvement",
|
||||
"Shell" => "Auto & Transport",
|
||||
"Home Depot" => "Housing",
|
||||
"Shell" => "Transportation",
|
||||
"Whole Foods" => "Food & Drink",
|
||||
"Walgreens" => "Personal Care",
|
||||
"Walgreens" => "Healthcare",
|
||||
"Nike" => "Shopping",
|
||||
"Uber" => "Auto & Transport",
|
||||
"Netflix" => "Entertainment",
|
||||
"Spotify" => "Entertainment",
|
||||
"Delta Airlines" => "Travel",
|
||||
"Airbnb" => "Travel",
|
||||
"Sephora" => "Personal Care"
|
||||
"Uber" => "Transportation",
|
||||
"Netflix" => "Subscriptions",
|
||||
"Spotify" => "Subscriptions",
|
||||
"Delta Airlines" => "Transportation",
|
||||
"Airbnb" => "Housing",
|
||||
"Sephora" => "Shopping"
|
||||
}
|
||||
|
||||
categories.find { |c| c.name == mapping[merchant.name] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Family < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
|
||||
DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
|
||||
DATE_FORMATS = [ "%m-%d-%Y", "%d.%m.%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
|
||||
|
||||
include Providable
|
||||
|
||||
@@ -17,6 +17,8 @@ class Family < ApplicationRecord
|
||||
has_many :issues, through: :accounts
|
||||
has_many :holdings, through: :accounts
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
has_many :budgets, dependent: :destroy
|
||||
has_many :budget_categories, through: :budgets
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS }
|
||||
@@ -56,6 +58,22 @@ class Family < ApplicationRecord
|
||||
).link_token
|
||||
end
|
||||
|
||||
def income_categories_with_totals(date: Date.current)
|
||||
categories_with_stats(classification: "income", date: date)
|
||||
end
|
||||
|
||||
def expense_categories_with_totals(date: Date.current)
|
||||
categories_with_stats(classification: "expense", date: date)
|
||||
end
|
||||
|
||||
def category_stats
|
||||
CategoryStats.new(self)
|
||||
end
|
||||
|
||||
def budgeting_stats
|
||||
BudgetingStats.new(self)
|
||||
end
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
@@ -82,7 +100,9 @@ class Family < ApplicationRecord
|
||||
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:entries)
|
||||
results = accounts.active
|
||||
.joins(:entries)
|
||||
.joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
|
||||
@@ -90,8 +110,8 @@ class Family < ApplicationRecord
|
||||
)
|
||||
.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")
|
||||
.where("account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("transfers.id IS NULL")
|
||||
.group("accounts.id")
|
||||
.having("SUM(ABS(account_entries.amount)) > 0")
|
||||
.to_a
|
||||
@@ -110,9 +130,7 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def snapshot_transactions
|
||||
candidate_entries = entries.account_transactions.without_transfers.excluding(
|
||||
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
|
||||
)
|
||||
candidate_entries = entries.account_transactions.incomes_and_expenses
|
||||
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
|
||||
|
||||
spending = []
|
||||
@@ -131,7 +149,7 @@ class Family < ApplicationRecord
|
||||
|
||||
savings << {
|
||||
date: r.date,
|
||||
value: r.rolling_income != 0 ? (r.rolling_income - r.rolling_spend) / r.rolling_income : 0.to_d
|
||||
value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d
|
||||
}
|
||||
end
|
||||
|
||||
@@ -173,4 +191,41 @@ class Family < ApplicationRecord
|
||||
def primary_user
|
||||
users.order(:created_at).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
entries.order(:date).first&.date || Date.current
|
||||
end
|
||||
|
||||
private
|
||||
CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
|
||||
CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
|
||||
|
||||
def categories_with_stats(classification:, date: Date.current)
|
||||
totals = category_stats.month_category_totals(date: date)
|
||||
|
||||
classified_totals = totals.category_totals.select { |t| t.classification == classification }
|
||||
|
||||
if classification == "income"
|
||||
total = totals.total_income
|
||||
categories_scope = categories.incomes
|
||||
else
|
||||
total = totals.total_expense
|
||||
categories_scope = categories.expenses
|
||||
end
|
||||
|
||||
categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
|
||||
|
||||
CategoriesWithTotals.new(
|
||||
total_money: Money.new(total, currency),
|
||||
category_totals: categories_with_uncategorized.map do |category|
|
||||
ct = classified_totals.find { |ct| ct.category_id == category&.id }
|
||||
|
||||
CategoryWithStats.new(
|
||||
category: category,
|
||||
amount_money: Money.new(ct&.amount || 0, currency),
|
||||
percentage: ct&.percentage || 0
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.marked_as_transfer = transfer?(plaid_txn)
|
||||
t.entryable = Account::Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn.merchant_name)
|
||||
|
||||
@@ -31,7 +31,6 @@ class PlaidInvestmentSync
|
||||
t.amount = transaction.amount
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
|
||||
t.entryable = Account::Transaction.new
|
||||
end
|
||||
else
|
||||
|
||||
@@ -74,7 +74,7 @@ class Provider::Plaid
|
||||
client_name: "Maybe Finance",
|
||||
products: [ get_primary_product(accountable_type) ],
|
||||
additional_consented_products: get_additional_consented_products(accountable_type),
|
||||
country_codes: [ "US" ],
|
||||
country_codes: [ "US", "CA" ],
|
||||
language: "en",
|
||||
webhook: webhooks_url,
|
||||
redirect_uri: redirect_url,
|
||||
|
||||
@@ -167,6 +167,35 @@ class Provider::Synth
|
||||
raw_response: response
|
||||
end
|
||||
|
||||
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
|
||||
params = {
|
||||
description: description,
|
||||
amount: amount,
|
||||
date: date,
|
||||
city: city,
|
||||
state: state,
|
||||
country: country
|
||||
}.compact
|
||||
|
||||
response = client.get("#{base_url}/enrich", params)
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
|
||||
EnrichTransactionResponse.new \
|
||||
info: EnrichTransactionInfo.new(
|
||||
name: parsed.dig("merchant"),
|
||||
icon_url: parsed.dig("icon"),
|
||||
category: parsed.dig("category")
|
||||
),
|
||||
success?: true,
|
||||
raw_response: response
|
||||
rescue StandardError => error
|
||||
EnrichTransactionResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key
|
||||
@@ -177,6 +206,8 @@ class Provider::Synth
|
||||
UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true
|
||||
SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true
|
||||
SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
|
||||
EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
|
||||
EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true
|
||||
|
||||
def base_url
|
||||
ENV["SYNTH_URL"] || "https://api.synthfinance.com"
|
||||
|
||||
@@ -34,6 +34,11 @@ class Sync < ApplicationRecord
|
||||
scope.set_context("sync", { id: id })
|
||||
end
|
||||
|
||||
update! status: :failed, error: error.message, last_ran_at: Time.current
|
||||
update!(
|
||||
status: :failed,
|
||||
error: error.message,
|
||||
error_backtrace: error.backtrace&.first(10),
|
||||
last_ran_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
158
app/models/transfer.rb
Normal file
158
app/models/transfer.rb
Normal file
@@ -0,0 +1,158 @@
|
||||
class Transfer < ApplicationRecord
|
||||
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||
|
||||
enum :status, { pending: "pending", confirmed: "confirmed", rejected: "rejected" }
|
||||
|
||||
validate :transfer_has_different_accounts
|
||||
validate :transfer_has_opposite_amounts
|
||||
validate :transfer_within_date_range
|
||||
validate :transfer_has_same_family
|
||||
validate :inflow_on_or_after_outflow
|
||||
|
||||
class << self
|
||||
def from_accounts(from_account:, to_account:, date:, amount:)
|
||||
# Attempt to convert the amount to the to_account's currency.
|
||||
# If the conversion fails, use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
new(
|
||||
inflow_transaction: Account::Transaction.new(
|
||||
entry: to_account.entries.build(
|
||||
amount: converted_amount.amount.abs * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
),
|
||||
outflow_transaction: Account::Transaction.new(
|
||||
entry: from_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
),
|
||||
status: "confirmed"
|
||||
)
|
||||
end
|
||||
|
||||
def auto_match_for_account(account)
|
||||
matches = Account::Entry.select([
|
||||
"inflow_candidates.entryable_id as inflow_transaction_id",
|
||||
"outflow_candidates.entryable_id as outflow_transaction_id"
|
||||
]).from("account_entries inflow_candidates")
|
||||
.joins("
|
||||
JOIN account_entries outflow_candidates ON (
|
||||
inflow_candidates.amount < 0 AND
|
||||
outflow_candidates.amount > 0 AND
|
||||
inflow_candidates.amount = -outflow_candidates.amount AND
|
||||
inflow_candidates.currency = outflow_candidates.currency AND
|
||||
inflow_candidates.account_id <> outflow_candidates.account_id AND
|
||||
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 AND
|
||||
inflow_candidates.date >= outflow_candidates.date
|
||||
)
|
||||
").joins("
|
||||
LEFT JOIN transfers existing_transfers ON (
|
||||
existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
|
||||
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
|
||||
)
|
||||
")
|
||||
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
|
||||
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
|
||||
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", account.family_id, account.family_id)
|
||||
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
|
||||
.where(existing_transfers: { id: nil })
|
||||
|
||||
Transfer.transaction do
|
||||
matches.each do |match|
|
||||
Transfer.create!(
|
||||
inflow_transaction_id: match.inflow_transaction_id,
|
||||
outflow_transaction_id: match.outflow_transaction_id,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction.entry.sync_account_later
|
||||
outflow_transaction.entry.sync_account_later
|
||||
end
|
||||
|
||||
def belongs_to_family?(family)
|
||||
family.transactions.include?(inflow_transaction)
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def amount_abs
|
||||
inflow_transaction.entry.amount_money.abs
|
||||
end
|
||||
|
||||
def name
|
||||
if payment?
|
||||
I18n.t("transfer.payment_name", to_account: to_account.name)
|
||||
else
|
||||
I18n.t("transfer.name", to_account: to_account.name)
|
||||
end
|
||||
end
|
||||
|
||||
def payment?
|
||||
to_account.liability?
|
||||
end
|
||||
|
||||
def categorizable?
|
||||
to_account.accountable_type == "Loan"
|
||||
end
|
||||
|
||||
private
|
||||
def inflow_on_or_after_outflow
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :inflow_must_be_on_or_after_outflow) if inflow_transaction.entry.date < outflow_transaction.entry.date
|
||||
end
|
||||
|
||||
def transfer_has_different_accounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def transfer_has_same_family
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_same_family) unless inflow_transaction.entry.account.family == outflow_transaction.entry.account.family
|
||||
end
|
||||
|
||||
def transfer_has_opposite_amounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
||||
inflow_amount = inflow_transaction.entry.amount
|
||||
outflow_amount = outflow_transaction.entry.amount
|
||||
|
||||
if inflow_transaction.entry.currency == outflow_transaction.entry.currency
|
||||
# For same currency, amounts must be exactly opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0
|
||||
else
|
||||
# For different currencies, just check the signs are opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_within_date_range
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
||||
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
|
||||
errors.add(:base, :must_be_within_date_range) if date_diff > 4
|
||||
end
|
||||
end
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">
|
||||
<%= render "pagination", pagy: @pagy %>
|
||||
<%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page], tab: params[:tab]) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{ label: t(".type"), selected: type },
|
||||
{ data: {
|
||||
action: "trade-form#changeType",
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_key_param: "type",
|
||||
}} %>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
|
||||
</div>
|
||||
|
||||
<% if trade.buy? %>
|
||||
<% if trade.qty.positive? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
|
||||
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
|
||||
<% if trade.qty.positive? && trade.unrealized_gain_loss.present? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
|
||||
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<% trade, account = entry.account_trade, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
<div class="col-span-6 flex items-center gap-4">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
@@ -13,14 +13,14 @@
|
||||
<div class="max-w-full">
|
||||
<%= tag.div class: ["flex items-center gap-2"] do %>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= trade.name.first.upcase %>
|
||||
<%= entry.display_name.first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, trade.name %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to trade.name,
|
||||
<%= link_to entry.display_name,
|
||||
account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
@@ -30,6 +30,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex items-center">
|
||||
<%= render "categories/badge", category: trade_category %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm">
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||
<%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %>
|
||||
<%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %>
|
||||
<%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, 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 %>
|
||||
<%= link_to new_transfer_path, data: { turbo_frame: :modal }, 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 %>
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".transfer") %>
|
||||
<% end %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<% if entry.marked_as_transfer? %>
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -8,26 +8,6 @@
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= form_with url: mark_transfers_account_transactions_path,
|
||||
scope: "bulk_update",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".mark_transfers"),
|
||||
body: t(".mark_transfers_message"),
|
||||
accept: t(".mark_transfers_confirm"),
|
||||
}
|
||||
} do |f| %>
|
||||
<button id="bulk-transfer-btn"
|
||||
type="button"
|
||||
data-bulk-select-scope-param="bulk_update"
|
||||
data-action="bulk-select#submitBulkRequest"
|
||||
class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md"
|
||||
title="Mark as transfer">
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= link_to bulk_edit_account_transactions_path,
|
||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||
title: "Edit",
|
||||
|
||||
@@ -1,69 +1,67 @@
|
||||
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||
<div class="pr-10 flex items-center gap-4 col-span-6">
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="pr-10 flex items-center gap-4 <%= balance_trend ? "col-span-6" : "col-span-8" %>">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
disabled: entry.account_transaction.transfer?,
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-full">
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= transaction.name.first.upcase %>
|
||||
</div>
|
||||
<% if transaction.merchant&.icon_url %>
|
||||
<%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %>
|
||||
<% else %>
|
||||
<%= render "shared/circle_logo", name: entry.display_name, size: "sm" %>
|
||||
<% end %>
|
||||
|
||||
<div class="truncate">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, transaction.name %>
|
||||
<% else %>
|
||||
<%= link_to transaction.name,
|
||||
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to entry.account_transaction.transfer? ? entry.account_transaction.transfer.name : entry.display_name,
|
||||
entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if entry.excluded %>
|
||||
<span title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
||||
<%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= render "account/transactions/transfer_match", entry: entry %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs font-normal">
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %>
|
||||
<% else %>
|
||||
<%= link_to entry.account.name, account_path(entry.account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if unconfirmed_transfer?(entry) %>
|
||||
<%= render "account/transfers/transfer_toggle", entry: entry %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if entry.transfer.present? %>
|
||||
<% unless balance_trend %>
|
||||
<div class="col-span-2"></div>
|
||||
<% end %>
|
||||
|
||||
<div class="col-span-2">
|
||||
<%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.outflow? %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<% unless balance_trend %>
|
||||
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
|
||||
<% if entry.new_record? %>
|
||||
<%= tag.p account.name %>
|
||||
<% else %>
|
||||
<%= link_to account.name,
|
||||
account_path(account, tab: "transactions"),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: "hover:underline" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "account/transactions/transaction_category", entry: entry %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.inflow?] %>
|
||||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
</div>
|
||||
|
||||
<% if balance_trend %>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<div id="<%= dom_id(entry, "category_menu") %>">
|
||||
<% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? || entry.account_transaction.transfer&.rejected? %>
|
||||
<%= render "categories/menu", transaction: entry.account_transaction %>
|
||||
<% else %>
|
||||
<%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %>
|
||||
<% end %>
|
||||
</div>
|
||||
27
app/views/account/transactions/_transfer_match.html.erb
Normal file
27
app/views/account/transactions/_transfer_match.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<div id="<%= dom_id(entry, "transfer_match") %>" class="flex items-center gap-1">
|
||||
<% if entry.account_transaction.transfer.confirmed? %>
|
||||
<span title="<%= entry.account_transaction.transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
||||
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
|
||||
</span>
|
||||
<% elsif entry.account_transaction.transfer.pending? %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||
Auto-matched
|
||||
</span>
|
||||
|
||||
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "confirmed" }),
|
||||
method: :patch,
|
||||
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||
title: "Confirm match" do %>
|
||||
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }),
|
||||
method: :patch,
|
||||
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||
title: "Reject match" do %>
|
||||
<%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -9,7 +9,8 @@
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_field :name,
|
||||
|
||||
<%= f.text_field @entry.enriched_at.present? ? :enriched_name : :name,
|
||||
label: t(".name_label"),
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
@@ -18,7 +19,7 @@
|
||||
max: Date.current,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless @entry.marked_as_transfer? %>
|
||||
<% unless @entry.account_transaction.transfer? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||
@@ -31,27 +32,7 @@
|
||||
min: 0,
|
||||
value: @entry.amount.abs %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
@entry.account_id
|
||||
),
|
||||
{ label: t(".account_label") },
|
||||
{ disabled: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<% unless @entry.marked_as_transfer? %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.collection_select :category_id,
|
||||
Current.family.categories.alphabetically,
|
||||
@@ -59,6 +40,30 @@
|
||||
{ label: t(".category_label"),
|
||||
class: "text-gray-400", include_blank: t(".uncategorized") },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details"), default_open: false do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<% unless @entry.account_transaction.transfer? %>
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
@entry.account_id
|
||||
),
|
||||
{ label: t(".account_label") },
|
||||
{ disabled: true } %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
@@ -93,15 +98,15 @@
|
||||
<!-- Settings Section -->
|
||||
<%= disclosure t(".settings") do %>
|
||||
<div class="pb-4">
|
||||
<!-- Exclude Transaction Form -->
|
||||
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
<h4 class="text-gray-900">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
|
||||
<p class="text-gray-500">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none">
|
||||
@@ -114,6 +119,18 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900">Transfer or Debt Payment?</h4>
|
||||
<p class="text-gray-500">Transfers and payments are special types of transactions that indicate money movement between 2 accounts.</p>
|
||||
</div>
|
||||
|
||||
<%= link_to new_account_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "w-4 h-4 shrink-0" %>
|
||||
<span class="whitespace-nowrap">Open matcher</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Delete Transaction Form -->
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
|
||||
44
app/views/account/transfer_matches/_matching_fields.html.erb
Normal file
44
app/views/account/transfer_matches/_matching_fields.html.erb
Normal file
@@ -0,0 +1,44 @@
|
||||
<%# locals: (form:, entry:, candidates:, accounts:) %>
|
||||
|
||||
<% if candidates.any? %>
|
||||
<div data-controller="transfer-match" class="space-y-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
Select a method for matching your transactions.
|
||||
</p>
|
||||
|
||||
<%= form.select :method,
|
||||
[
|
||||
["Match existing transaction (recommended)", "existing"],
|
||||
["Create new transaction", "new"]
|
||||
],
|
||||
{ selected: "existing", label: "Matching method" },
|
||||
data: { action: "change->transfer-match#update" } %>
|
||||
|
||||
<div data-transfer-match-target="existingSelect">
|
||||
<%= form.select :matched_entry_id,
|
||||
candidates.map { |entry|
|
||||
[entry_name_detailed(entry), entry.id]
|
||||
},
|
||||
{ label: "Matching transaction" } %>
|
||||
</div>
|
||||
|
||||
<div data-transfer-match-target="newSelect" class="hidden">
|
||||
<%= form.select :target_account_id,
|
||||
accounts.map { |account| [account.name, account.id] },
|
||||
{ label: "Target account" } %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">
|
||||
We couldn't find any transactions to match from your other accounts.
|
||||
Please select an account and we will create a new inflow transaction for you.
|
||||
</p>
|
||||
|
||||
<%= form.hidden_field :method, value: "new" %>
|
||||
|
||||
<div>
|
||||
<%= form.select :target_account_id,
|
||||
accounts.map { |account| [account.name, account.id] },
|
||||
{ label: "Target account" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
60
app/views/account/transfer_matches/new.html.erb
Normal file
60
app/views/account/transfer_matches/new.html.erb
Normal file
@@ -0,0 +1,60 @@
|
||||
<%= modal_form_wrapper title: "Match transfer or payment" do %>
|
||||
<%= styled_form_with(
|
||||
url: account_transaction_transfer_match_path(@entry),
|
||||
scope: :transfer_match,
|
||||
class: "space-y-8",
|
||||
data: { turbo_frame: :_top }
|
||||
) do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-sm font-medium text-gray-700">
|
||||
<%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %>
|
||||
</h2>
|
||||
|
||||
<% if @entry.amount.positive? %>
|
||||
<%= f.select(
|
||||
:entry_id,
|
||||
[[entry_name_detailed(@entry), @entry.id]],
|
||||
{
|
||||
label: "Outflow transaction",
|
||||
selected: @entry.id,
|
||||
},
|
||||
disabled: true
|
||||
) %>
|
||||
<% else %>
|
||||
<%= render "account/transfer_matches/matching_fields",
|
||||
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<%= lucide_icon "arrow-down", class: "w-5 h-5" %>
|
||||
</div>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-sm font-medium text-gray-700">
|
||||
<%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %>
|
||||
</h2>
|
||||
|
||||
<% if @entry.amount.negative? %>
|
||||
<%= f.select(
|
||||
:entry_id,
|
||||
[[entry_name_detailed(@entry), @entry.id]],
|
||||
{
|
||||
label: "Inflow transaction",
|
||||
selected: @entry.id,
|
||||
},
|
||||
disabled: true
|
||||
) %>
|
||||
<% else %>
|
||||
<%= render "account/transfer_matches/matching_fields",
|
||||
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= f.submit "Create transfer match", data: { turbo_submits_with: "Saving..."} %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,25 +0,0 @@
|
||||
<%# locals: (transfer:, outflow: false) %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if outflow %>
|
||||
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
|
||||
<%= lucide_icon "arrow-right", class: "text-gray-500 w-4 h-4" %>
|
||||
|
||||
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
|
||||
<%= lucide_icon "arrow-left", class: "text-gray-500 w-4 h-4" %>
|
||||
|
||||
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,16 +0,0 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: {
|
||||
turbo_confirm: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
accept: t(".remove_transfer_confirm"),
|
||||
},
|
||||
turbo_frame: "_top"
|
||||
} do |f| %>
|
||||
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
|
||||
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
|
||||
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -8,6 +8,7 @@
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-3">
|
||||
<%= form.hidden_field :name, value: "Balance update" %>
|
||||
<%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %>
|
||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||
</div>
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
|
||||
<%= lucide_icon icon, class: "w-4 h-4" %>
|
||||
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
|
||||
<%= lucide_icon icon, class: "w-4 h-4 shrink-0" %>
|
||||
<% end %>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.name %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to entry.name || t(".balance_update"),
|
||||
<%= link_to entry.display_name,
|
||||
account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%# locals: (account:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %>
|
||||
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page], tab: params[:tab]) do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<%# locals: (pagy:) %>
|
||||
<%# locals: (pagy:, current_path: nil) %>
|
||||
<nav class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<div>
|
||||
<% if pagy.prev %>
|
||||
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= link_to custom_pagy_url_for(pagy, pagy.prev, current_path: current_path),
|
||||
class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
|
||||
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
@@ -15,11 +17,15 @@
|
||||
<div class="rounded-xl p-1 bg-gray-25">
|
||||
<% pagy.series.each do |series_item| %>
|
||||
<% if series_item.is_a?(Integer) %>
|
||||
<%= link_to pagy_url_for(pagy, series_item), class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
|
||||
class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
|
||||
<%= series_item %>
|
||||
<% end %>
|
||||
<% elsif series_item.is_a?(String) %>
|
||||
<%= link_to pagy_url_for(pagy, series_item), class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900" do %>
|
||||
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
|
||||
class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900",
|
||||
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
|
||||
<%= series_item %>
|
||||
<% end %>
|
||||
<% elsif series_item == :gap %>
|
||||
@@ -29,7 +35,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<% if pagy.next %>
|
||||
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= link_to custom_pagy_url_for(pagy, pagy.next, current_path: current_path),
|
||||
class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
@@ -40,16 +48,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form_with url: url_for,
|
||||
<%= form_with url: custom_pagy_url_for(pagy, pagy.page, current_path: current_path),
|
||||
method: :get,
|
||||
class: "flex items-center gap-4",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
|
||||
<%= f.select :per_page,
|
||||
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
|
||||
<%= f.select :per_page,
|
||||
options_for_select(["10", "20", "30", "50"], pagy.limit),
|
||||
{},
|
||||
class: "py-1.5 pr-8 text-sm text-gray-900 font-medium border border-gray-200 rounded-lg focus:border-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900",
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
26
app/views/budget_categories/_allocation_progress.erb
Normal file
26
app/views/budget_categories/_allocation_progress.erb
Normal file
@@ -0,0 +1,26 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="rounded-full w-1.5 h-1.5 <%= budget.allocated_spending > 0 ? "bg-gray-900" : "bg-gray-100" %>"></div>
|
||||
|
||||
<p class="text-gray-500 text-sm">
|
||||
<%= number_to_percentage(budget.allocated_percent, precision: 0) %> set
|
||||
</p>
|
||||
|
||||
<p class="ml-auto text-sm space-x-1">
|
||||
<span class="text-gray-900"><%= format_money(budget.allocated_spending_money) %></span>
|
||||
<span class="text-gray-500"> / </span>
|
||||
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative h-1.5 rounded-2xl bg-gray-100">
|
||||
<div class="absolute inset-0 bg-gray-900 rounded-2xl" style="width: <%= budget.allocated_percent %>%;"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-900"><%= format_money(budget.available_to_allocate_money) %></span>
|
||||
<span class="text-gray-500">left to allocate</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="rounded-full w-1.5 h-1.5 bg-red-500"></div>
|
||||
|
||||
<p class="text-gray-900 text-sm">> 100% set</p>
|
||||
|
||||
<p class="ml-auto text-sm space-x-1">
|
||||
<span class="text-red-500"><%= format_money(budget.allocated_spending_money) %></span>
|
||||
<span class="text-gray-500"> / </span>
|
||||
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative h-1.5 rounded-2xl bg-gray-100">
|
||||
<div class="absolute inset-0 bg-red-500 rounded-2xl" style="width: 100%;"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<p class="text-gray-500">
|
||||
Budget exceeded by <span class="text-red-500"><%= format_money(budget.available_to_allocate_money.abs) %></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
48
app/views/budget_categories/_budget_category.html.erb
Normal file
48
app/views/budget_categories/_budget_category.html.erb
Normal file
@@ -0,0 +1,48 @@
|
||||
<%# locals: (budget_category:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %>
|
||||
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-white", data: { turbo_frame: "drawer" } do %>
|
||||
|
||||
<% if budget_category.initialized? %>
|
||||
<div class="w-10 h-10 group-hover:scale-105 transition-all duration-300">
|
||||
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="<%= mixed_hex_styles(budget_category.category.color) %>">
|
||||
<% if budget_category.category.lucide_icon %>
|
||||
<%= icon(budget_category.category.lucide_icon) %>
|
||||
<% else %>
|
||||
<%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900"><%= budget_category.category.name %></p>
|
||||
|
||||
<% if budget_category.initialized? %>
|
||||
<% if budget_category.available_to_spend.negative? %>
|
||||
<p class="text-sm font-medium text-red-500"><%= format_money(budget_category.available_to_spend_money.abs) %> over</p>
|
||||
<% elsif budget_category.available_to_spend.zero? %>
|
||||
<p class="text-sm font-medium <%= budget_category.budgeted_spending.positive? ? "text-orange-500" : "text-gray-500" %>">
|
||||
<%= format_money(budget_category.available_to_spend_money) %> left
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 font-medium"><%= format_money(budget_category.available_to_spend_money) %> left</p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 font-medium">
|
||||
<%= format_money(budget_category.category.avg_monthly_total) %> avg
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto text-right">
|
||||
<p class="text-sm font-medium text-gray-900"><%= format_money(budget_category.actual_spending_money) %></p>
|
||||
|
||||
<% if budget_category.initialized? %>
|
||||
<p class="text-sm text-gray-500">from <%= format_money(budget_category.budgeted_spending_money) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
22
app/views/budget_categories/_budget_category_donut.html.erb
Normal file
22
app/views/budget_categories/_budget_category_donut.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<%# locals: (budget_category:) %>
|
||||
|
||||
<%= tag.div data: {
|
||||
controller: "donut-chart",
|
||||
donut_chart_segments_value: budget_category.to_donut_segments_json,
|
||||
donut_chart_segment_height_value: 5,
|
||||
donut_chart_segment_opacity_value: 0.2
|
||||
}, class: "relative h-full" do %>
|
||||
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
|
||||
|
||||
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full p-1">
|
||||
<div data-donut-chart-target="defaultContent" class="h-full w-full rounded-full flex flex-col items-center justify-center" style="background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>">
|
||||
<% if budget_category.category.lucide_icon %>
|
||||
<%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %>
|
||||
<% else %>
|
||||
<span class="text-sm uppercase" style="color: <%= budget_category.category.color %>">
|
||||
<%= budget_category.category.name.first.upcase %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
29
app/views/budget_categories/_budget_category_form.html.erb
Normal file
29
app/views/budget_categories/_budget_category_form.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%# locals: (budget_category:) %>
|
||||
|
||||
<% currency = Money::Currency.new(budget_category.budget.currency) %>
|
||||
|
||||
<div class="w-full flex gap-3">
|
||||
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
|
||||
|
||||
<div class="text-sm mr-3">
|
||||
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
|
||||
|
||||
<p class="text-gray-500"><%= format_money(Money.new(budget_category.category.avg_monthly_total, budget_category.currency), precision: 0) %>/m average</p>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur", turbo_frame: :_top } do |f| %>
|
||||
<div class="form-field w-[120px]">
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-500 text-sm mr-2"><%= currency.symbol %></span>
|
||||
<%= f.number_field :budgeted_spending,
|
||||
class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
||||
placeholder: "0",
|
||||
step: currency.step,
|
||||
min: 0,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
17
app/views/budget_categories/_no_categories.html.erb
Normal file
17
app/views/budget_categories/_no_categories.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||
<h2 class="text-lg text-gray-900 font-medium">Oops!</h2>
|
||||
<p class="text-gray-500 text-sm max-w-sm mx-auto mb-4">
|
||||
You have not created or assigned any expense categories to your transactions yet.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %>
|
||||
|
||||
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span>New category</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<% budget_category = budget.uncategorized_budget_category %>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
|
||||
|
||||
<div class="text-sm mr-3">
|
||||
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
|
||||
<p class="text-gray-500"><%= format_money(Money.new(budget_category.category.avg_monthly_total, budget_category.category.family.currency), precision: 0) %>/m average</p>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<div class="form-field w-[120px]">
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-400 text-sm mr-2">$</span>
|
||||
<%= text_field_tag :uncategorized, budget_category.budgeted_spending_money, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
app/views/budget_categories/index.html.erb
Normal file
65
app/views/budget_categories/index.html.erb
Normal file
@@ -0,0 +1,65 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "budgets/budget_nav", budget: @budget %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, edit_budget_path(@budget) %>
|
||||
<%= content_for :cancel_path, budget_path(@budget) %>
|
||||
|
||||
<div>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-gray-900 font-medium">Edit your category budgets</h1>
|
||||
<p class="text-gray-500 text-sm max-w-md mx-auto">
|
||||
Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg">
|
||||
<% if @budget.family.categories.empty? %>
|
||||
<div class="bg-white shadow-xs border border-gray-200 rounded-lg p-4">
|
||||
<%= render "budget_categories/no_categories" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-md mx-auto">
|
||||
<% if @budget.available_to_allocate.negative? %>
|
||||
<%= render "budget_categories/allocation_progress_overage", budget: @budget %>
|
||||
<% else %>
|
||||
<%= render "budget_categories/allocation_progress", budget: @budget %>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<% BudgetCategory::Group.for(@budget.budget_categories).sort_by(&:name).each do |group| %>
|
||||
<div class="space-y-4">
|
||||
<%= render "budget_categories/budget_category_form", budget_category: group.budget_category %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% group.budget_subcategories.each do |budget_subcategory| %>
|
||||
<div class="w-full flex items-center gap-4">
|
||||
<div class="ml-4 flex items-center justify-center text-gray-400">
|
||||
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
|
||||
</div>
|
||||
|
||||
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "budget_categories/uncategorized_budget_category_form", budget: @budget %>
|
||||
</div>
|
||||
|
||||
<% if @budget.allocations_valid? %>
|
||||
<%= link_to "Confirm",
|
||||
budget_path(@budget),
|
||||
class: "block btn btn--primary w-full text-center" %>
|
||||
<% else %>
|
||||
<span class="block btn btn--secondary w-full text-center text-gray-400 cursor-not-allowed">
|
||||
Confirm
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
150
app/views/budget_categories/show.html.erb
Normal file
150
app/views/budget_categories/show.html.erb
Normal file
@@ -0,0 +1,150 @@
|
||||
<%= drawer do %>
|
||||
<div class="space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Category</p>
|
||||
<h3 class="text-2xl font-medium text-gray-900">
|
||||
<%= @budget_category.category.name %>
|
||||
</h3>
|
||||
|
||||
<% if @budget_category.budget.initialized? %>
|
||||
<p class="text-sm text-gray-500">
|
||||
<span class="text-gray-900">
|
||||
<%= format_money(@budget_category.actual_spending) %>
|
||||
</span>
|
||||
<span>/</span>
|
||||
<span><%= format_money(@budget_category.budgeted_spending) %></span>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @budget_category.budget.initialized? %>
|
||||
<div class="ml-auto w-10 h-10">
|
||||
<%= render "budget_categories/budget_category_donut",
|
||||
budget_category: @budget_category %>
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
|
||||
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4>Overview</h4>
|
||||
<%= lucide_icon "chevron-down",
|
||||
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-4">
|
||||
<dl class="space-y-3 px-3 py-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500">
|
||||
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
|
||||
</dt>
|
||||
<dd class="text-gray-900 font-medium">
|
||||
<%= format_money @budget_category.actual_spending_money %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<% if @budget_category.budget.initialized? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500">Status</dt>
|
||||
<% if @budget_category.available_to_spend.negative? %>
|
||||
<dd class="text-red-500 flex items-center gap-1 text-red-500 font-medium">
|
||||
<%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %>
|
||||
<%= format_money @budget_category.available_to_spend_money.abs %>
|
||||
<span>overspent</span>
|
||||
</dd>
|
||||
<% elsif @budget_category.available_to_spend.zero? %>
|
||||
<dd class="text-orange-500 flex items-center gap-1 text-orange-500 font-medium">
|
||||
<%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %>
|
||||
<%= format_money @budget_category.available_to_spend_money %>
|
||||
<span>left</span>
|
||||
</dd>
|
||||
<% else %>
|
||||
<dd class="text-gray-900 flex items-center gap-1 text-green-500 font-medium">
|
||||
<%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %>
|
||||
<%= format_money @budget_category.available_to_spend_money %>
|
||||
<span>left</span>
|
||||
</dd>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500">Budgeted</dt>
|
||||
<dd class="text-gray-900 font-medium">
|
||||
<%= format_money @budget_category.budgeted_spending %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500">Monthly average spending</dt>
|
||||
<dd class="text-gray-900 font-medium">
|
||||
<%= format_money @budget_category.category.avg_monthly_total %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500">Monthly median spending</dt>
|
||||
<dd class="text-gray-900 font-medium">
|
||||
<%= format_money @budget_category.category.median_monthly_total %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
|
||||
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4>Recent Transactions</h4>
|
||||
<%= lucide_icon "chevron-down",
|
||||
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="px-3 py-4 space-y-2">
|
||||
<% if @recent_transactions.any? %>
|
||||
<ul class="space-y-2 mb-4">
|
||||
<% @recent_transactions.each_with_index do |entry, index| %>
|
||||
<li class="flex gap-4 text-sm space-y-1">
|
||||
<div class="flex flex-col items-center gap-1.5 pt-2">
|
||||
<div class="rounded-full h-1.5 w-1.5 bg-gray-300"></div>
|
||||
<% unless index == @recent_transactions.length - 1 %>
|
||||
<div class="h-12 w-px bg-alpha-black-200"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between w-full">
|
||||
<div>
|
||||
<p class="text-gray-500 text-xs uppercase">
|
||||
<%= entry.date.strftime("%b %d") %>
|
||||
</p>
|
||||
<p class="text-gray-900"><%= entry.name %></p>
|
||||
</div>
|
||||
<p class="text-gray-900 font-medium">
|
||||
<%= format_money entry.amount_money %>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= link_to "View all category transactions",
|
||||
transactions_path(q: {
|
||||
categories: [@budget_category.category.name],
|
||||
start_date: @budget.start_date,
|
||||
end_date: @budget.end_date
|
||||
}),
|
||||
data: { turbo_frame: :_top },
|
||||
class: "block text-center btn btn--outline w-full" %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm mb-4">
|
||||
No transactions found for this budget period.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<% end %>
|
||||
62
app/views/budgets/_actuals_summary.html.erb
Normal file
62
app/views/budgets/_actuals_summary.html.erb
Normal file
@@ -0,0 +1,62 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Income</h3>
|
||||
|
||||
<% income_totals = budget.income_categories_with_totals %>
|
||||
<% income_categories = income_totals.category_totals.reject { |ct| ct.amount_money.zero? }.sort_by { |ct| ct.percentage }.reverse %>
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
|
||||
<%= format_money(income_totals.total_money) %>
|
||||
</span>
|
||||
|
||||
<% if income_categories.any? %>
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% income_categories.each do |item| %>
|
||||
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
|
||||
<% income_categories.each do |item| %>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
|
||||
<span class="text-gray-500"><%= item.category.name %></span>
|
||||
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Expenses</h3>
|
||||
|
||||
<% expense_totals = budget.expense_categories_with_totals %>
|
||||
<% expense_categories = expense_totals.category_totals.reject { |ct| ct.amount_money.zero? || ct.category.subcategory? }.sort_by { |ct| ct.percentage }.reverse %>
|
||||
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900"><%= format_money(expense_totals.total_money) %></span>
|
||||
|
||||
<% if expense_categories.any? %>
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% expense_categories.each do |item| %>
|
||||
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
|
||||
<% expense_categories.each do |item| %>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
|
||||
<span class="text-gray-500"><%= item.category.name %></span>
|
||||
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
45
app/views/budgets/_budget_categories.html.erb
Normal file
45
app/views/budgets/_budget_categories.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||
<p>Categories</p>
|
||||
<span class="text-gray-400">·</span>
|
||||
<p><%= budget.budget_categories.count %></p>
|
||||
|
||||
<p class="ml-auto">Amount</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white py-1 shadow-xs border border-gray-100 rounded-md">
|
||||
<% if budget.family.categories.expenses.empty? %>
|
||||
<div class="py-8">
|
||||
<%= render "budget_categories/no_categories" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<% category_groups = BudgetCategory::Group.for(budget.budget_categories) %>
|
||||
|
||||
<% category_groups.each_with_index do |group, index| %>
|
||||
<div>
|
||||
<%= render "budget_categories/budget_category", budget_category: group.budget_category %>
|
||||
|
||||
<div>
|
||||
<% group.budget_subcategories.each do |budget_subcategory| %>
|
||||
<div class="w-full flex items-center -mt-4">
|
||||
<div class="ml-8 flex items-center justify-center text-gray-400">
|
||||
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
|
||||
</div>
|
||||
|
||||
<%= render "budget_categories/budget_category", budget_category: budget_subcategory %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
<div class="h-px w-full bg-alpha-black-50"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
61
app/views/budgets/_budget_donut.html.erb
Normal file
61
app/views/budgets/_budget_donut.html.erb
Normal file
@@ -0,0 +1,61 @@
|
||||
<%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, class: "relative h-full" do %>
|
||||
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
|
||||
|
||||
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full">
|
||||
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
|
||||
<% if budget.initialized? %>
|
||||
<div class="text-gray-600 text-sm mb-2">
|
||||
<span>Spent</span>
|
||||
</div>
|
||||
|
||||
<div class="text-3xl font-medium <%= budget.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
|
||||
<%= format_money(budget.actual_spending_money) %>
|
||||
</div>
|
||||
|
||||
<%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %>
|
||||
<span class="text-gray-900 font-medium">
|
||||
of <%= format_money(budget.budgeted_spending_money) %>
|
||||
</span>
|
||||
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-gray-400 text-3xl mb-2">
|
||||
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
|
||||
</div>
|
||||
<%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %>
|
||||
<%= lucide_icon "plus", class: "w-4 h-4 text-white" %>
|
||||
New budget
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% budget.budget_categories.each do |bc| %>
|
||||
<div id="segment_<%= bc.id %>" class="hidden">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-1 h-3 rounded-xl" style="background-color: <%= bc.category.color %>"></div>
|
||||
<p class="text-sm text-gray-500"><%= bc.category.name %></p>
|
||||
</div>
|
||||
|
||||
<p class="text-3xl font-medium <%= bc.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
|
||||
<%= format_money(bc.actual_spending_money) %>
|
||||
</p>
|
||||
|
||||
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
|
||||
<span>of <%= format_money(bc.budgeted_spending_money, precision: 0) %></span>
|
||||
|
||||
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 shrink-0" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="segment_unused" class="hidden">
|
||||
<p class="text-sm text-gray-500 text-center mb-2">Unused</p>
|
||||
|
||||
<p class="text-3xl font-medium text-gray-900">
|
||||
<%= format_money(budget.available_to_spend_money) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
40
app/views/budgets/_budget_header.html.erb
Normal file
40
app/views/budgets/_budget_header.html.erb
Normal file
@@ -0,0 +1,40 @@
|
||||
<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %>
|
||||
|
||||
<div class="flex items-center gap-1 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if @previous_budget %>
|
||||
<%= link_to budget_path(@previous_budget) do %>
|
||||
<%= lucide_icon "chevron-left" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= lucide_icon "chevron-left", class: "text-gray-400" %>
|
||||
<% end %>
|
||||
|
||||
<% if @next_budget %>
|
||||
<%= link_to budget_path(@next_budget) do %>
|
||||
<%= lucide_icon "chevron-right" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= lucide_icon "chevron-right", class: "text-gray-400" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div data-controller="menu" data-menu-placement-value="bottom-start">
|
||||
<%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-gray-50 rounded-md p-2" do %>
|
||||
<span class="text-gray-900 font-medium"><%= @budget.name %></span>
|
||||
<%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-gray-500" %>
|
||||
<% end %>
|
||||
|
||||
<div data-menu-target="content" class="hidden z-10">
|
||||
<%= render "budgets/picker", family: Current.family, year: Date.current.year %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<% if @budget.current? %>
|
||||
<span class="border border-alpha-black-200 text-gray-900 text-sm font-medium px-3 py-2 rounded-lg">Today</span>
|
||||
<% else %>
|
||||
<%= link_to "Today", budget_path(@latest_budget), class: "btn btn--outline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
37
app/views/budgets/_budget_nav.html.erb
Normal file
37
app/views/budgets/_budget_nav.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<% steps = [
|
||||
{ name: "Setup", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 },
|
||||
{ name: "Categories", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 },
|
||||
] %>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
<% steps.each_with_index do |step, idx| %>
|
||||
<li class="flex items-center gap-2 group">
|
||||
<% is_current = request.path == step[:path] %>
|
||||
|
||||
<% text_class = if is_current
|
||||
"text-gray-900"
|
||||
else
|
||||
step[:is_complete] ? "text-green-600" : "text-gray-500"
|
||||
end %>
|
||||
<% step_class = if is_current
|
||||
"bg-gray-900 text-white"
|
||||
else
|
||||
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
||||
end %>
|
||||
|
||||
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||
<%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
|
||||
</span>
|
||||
|
||||
<span><%= step[:name] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
63
app/views/budgets/_budgeted_summary.html.erb
Normal file
63
app/views/budgets/_budgeted_summary.html.erb
Normal file
@@ -0,0 +1,63 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Expected income</h3>
|
||||
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
|
||||
<%= format_money(budget.expected_income_money) %>
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% if budget.remaining_expected_income.negative? %>
|
||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= 100 - budget.surplus_percent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.surplus_percent %>%"></div>
|
||||
<% else %>
|
||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.actual_income_percent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.actual_income_percent %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<p class="text-gray-500"><%= format_money(budget.actual_income_money) %> earned</p>
|
||||
<p class="font-medium">
|
||||
<% if budget.remaining_expected_income.negative? %>
|
||||
<span class="text-green-500"><%= format_money(budget.remaining_expected_income_money.abs) %> over</span>
|
||||
<% else %>
|
||||
<span class="text-gray-900"><%= format_money(budget.remaining_expected_income_money) %> left</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Budgeted</h3>
|
||||
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
|
||||
<%= format_money(budget.budgeted_spending_money) %>
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% if budget.available_to_spend.negative? %>
|
||||
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= 100 - budget.overage_percent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-red-500" style="width: <%= budget.overage_percent %>%"></div>
|
||||
<% else %>
|
||||
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= budget.percent_of_budget_spent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.percent_of_budget_spent %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<p class="text-gray-500"><%= format_money(budget.actual_spending_money) %> spent</p>
|
||||
<p class="font-medium">
|
||||
<% if budget.available_to_spend.negative? %>
|
||||
<span class="text-red-500"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
|
||||
<% else %>
|
||||
<span class="text-gray-900"><%= format_money(budget.available_to_spend_money) %> left</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
13
app/views/budgets/_over_allocation_warning.html.erb
Normal file
13
app/views/budgets/_over_allocation_warning.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div class="flex flex-col gap-4 items-center justify-center h-full">
|
||||
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %>
|
||||
<p class="text-gray-500 text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
|
||||
|
||||
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
|
||||
<span class="text-gray-900 font-medium">
|
||||
Fix allocations
|
||||
</span>
|
||||
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
</div>
|
||||
49
app/views/budgets/_picker.html.erb
Normal file
49
app/views/budgets/_picker.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<%# locals: (family:, year:) %>
|
||||
|
||||
<%= turbo_frame_tag "budget_picker" do %>
|
||||
<div class="bg-white shadow-md border border-alpha-black-25 p-3 rounded-xl space-y-4">
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<% if year > family.oldest_entry_date.year %>
|
||||
<%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
|
||||
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-500" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
|
||||
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-400" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<span class="w-40 text-center px-3 py-2 border border-alpha-black-100 rounded-md" data-budget-picker-target="year">
|
||||
<%= year %>
|
||||
</span>
|
||||
|
||||
<% if year < Date.current.year %>
|
||||
<%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
|
||||
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-500" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
|
||||
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-400" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 text-sm text-center font-medium">
|
||||
<% Date::ABBR_MONTHNAMES.compact.each_with_index do |month_name, index| %>
|
||||
<% month_number = index + 1 %>
|
||||
<% start_date = Date.new(year, month_number) %>
|
||||
<% budget = family.budgets.for_date(start_date) %>
|
||||
|
||||
<% if budget %>
|
||||
<%= link_to month_name, budget_path(budget), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-gray-900 hover:bg-gray-100 rounded-md" %>
|
||||
<% elsif start_date >= family.oldest_entry_date.beginning_of_month && start_date <= Date.current %>
|
||||
<%= button_to budgets_path(budget: { start_date: start_date }), data: { turbo_frame: "_top" }, class: "block w-full px-3 py-2 text-gray-900 hover:bg-gray-100 rounded-md" do %>
|
||||
<%= month_name %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="px-3 py-2 text-gray-400 rounded-md"><%= month_name %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
47
app/views/budgets/edit.html.erb
Normal file
47
app/views/budgets/edit.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "budgets/budget_nav", budget: @budget %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, budget_path(@budget) %>
|
||||
<%= content_for :cancel_path, budget_path(@budget) %>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-gray-900 font-medium">Setup your budget</h1>
|
||||
<p class="text-gray-500 text-sm max-w-sm mx-auto">
|
||||
Enter your monthly earnings and planned spending below to setup your budget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg">
|
||||
<%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %>
|
||||
<%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %>
|
||||
<%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %>
|
||||
|
||||
<% if @budget.estimated_income && @budget.estimated_spending %>
|
||||
<div class="border border-alpha-black-100 rounded-lg p-3 flex">
|
||||
<%= lucide_icon "sparkles", class: "w-5 h-5 text-gray-500 shrink-0" %>
|
||||
<div class="ml-2 space-y-1 text-sm">
|
||||
<h4 class="text-gray-900">Autosuggest income & spending budget</h4>
|
||||
<p class="text-gray-500">
|
||||
This will be based on transaction history. AI can make mistakes, verify before continuing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none ml-6">
|
||||
<%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: {
|
||||
action: "change->budget-form#toggleAutoFill",
|
||||
budget_form_income_param: { key: "budget_expected_income", value: @budget.estimated_income },
|
||||
budget_form_spending_param: { key: "budget_budgeted_spending", value: @budget.estimated_spending }
|
||||
} %>
|
||||
<label for="auto_fill" class="maybe-switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= f.submit "Continue", class: "btn btn--primary w-full" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
69
app/views/budgets/show.html.erb
Normal file
69
app/views/budgets/show.html.erb
Normal file
@@ -0,0 +1,69 @@
|
||||
<div class="pb-12">
|
||||
<%= render "budgets/budget_header",
|
||||
budget: @budget,
|
||||
previous_budget: @previous_budget,
|
||||
next_budget: @next_budget,
|
||||
latest_budget: @latest_budget %>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-[300px] space-y-4">
|
||||
<div class="h-[300px] bg-white rounded-xl shadow-xs p-8 border border-gray-100">
|
||||
<% if @budget.available_to_allocate.negative? %>
|
||||
<%= render "budgets/over_allocation_warning", budget: @budget %>
|
||||
<% else %>
|
||||
<%= render "budgets/budget_donut", budget: @budget %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
|
||||
<div class="flex gap-2 mb-2 rounded-lg bg-alpha-black-25 p-1">
|
||||
<% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %>
|
||||
<% selected_tab = params[:tab].presence || "budgeted" %>
|
||||
|
||||
<%= link_to "Budgeted",
|
||||
budget_path(@budget, tab: "budgeted"),
|
||||
class: class_names(
|
||||
base_classes,
|
||||
"bg-white shadow-xs text-gray-900": selected_tab == "budgeted",
|
||||
"text-gray-500": selected_tab != "budgeted"
|
||||
) %>
|
||||
|
||||
<%= link_to "Actual",
|
||||
budget_path(@budget, tab: "actuals"),
|
||||
class: class_names(
|
||||
base_classes,
|
||||
"bg-white shadow-xs text-gray-900": selected_tab == "actuals",
|
||||
"text-gray-500": selected_tab != "actuals"
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
|
||||
<%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
|
||||
<%= render "budgets/actuals_summary", budget: @budget %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grow bg-white rounded-xl shadow-xs p-4 border border-gray-100">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium">Categories</h2>
|
||||
|
||||
<% if @budget.initialized? %>
|
||||
<%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %>
|
||||
<%= icon "settings-2", color: "gray" %>
|
||||
<span>Edit</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-25 rounded-xl p-1">
|
||||
<%= render "budgets/budget_categories", budget: @budget %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,16 @@
|
||||
<%# locals: (category:) %>
|
||||
<% category ||= null_category %>
|
||||
<% category ||= Category.uncategorized %>
|
||||
|
||||
<div>
|
||||
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border"
|
||||
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
|
||||
style="
|
||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
||||
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
|
||||
color: <%= category.color %>;">
|
||||
<% if category.lucide_icon.present? %>
|
||||
<%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %>
|
||||
<% end %>
|
||||
<%= category.name %>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<%# locals: (category:) %>
|
||||
|
||||
<div id="<%= dom_id(category) %>" class="flex justify-between items-center p-4 bg-white">
|
||||
<div id="<%= dom_id(category) %>" class="flex justify-between items-center px-4 pb-4 <%= "pt-4" unless category.subcategory? %> <%= "pb-4" unless category.subcategories.any? %> bg-white">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<% if category.subcategory? %>
|
||||
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400 ml-2" %>
|
||||
<% end %>
|
||||
|
||||
<%= render partial: "categories/badge", locals: { category: category } %>
|
||||
</div>
|
||||
<div class="justify-self-end">
|
||||
|
||||
25
app/views/categories/_category_list_group.html.erb
Normal file
25
app/views/categories/_category_list_group.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<%# locals: (title:, categories:) %>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||
<p><%= title %></p>
|
||||
<span class="text-gray-400">·</span>
|
||||
<p><%= categories.count %></p>
|
||||
</div>
|
||||
|
||||
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
<% Category::Group.for(categories).each_with_index do |group, idx| %>
|
||||
<%= render group.category %>
|
||||
|
||||
<% group.subcategories.each do |subcategory| %>
|
||||
<%= render subcategory %>
|
||||
<% end %>
|
||||
|
||||
<% unless idx == Category::Group.for(categories).count - 1 %>
|
||||
<%= render "categories/ruler" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +1,12 @@
|
||||
<%# locals: (category:, categories:) %>
|
||||
|
||||
<div data-controller="color-avatar">
|
||||
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
|
||||
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<% Category::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
@@ -12,8 +15,26 @@
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, data: { color_avatar_target: "name" } %>
|
||||
|
||||
<% if category.errors.any? %>
|
||||
<%= render "shared/form_errors", model: category %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-4">
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
|
||||
<div class="p-1 rounded cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent peer-checked:border-gray-500">
|
||||
<%= lucide_icon icon, class: "w-5 h-5" %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %>
|
||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<div class="w-80 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
|
||||
<div class="p-6 flex items-center justify-center">
|
||||
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<%= modal_form_wrapper title: t(".edit") do %>
|
||||
<%= render "form", category: @category %>
|
||||
<%= render "form", category: @category, categories: @categories %>
|
||||
<% end %>
|
||||
|
||||
@@ -14,27 +14,27 @@
|
||||
|
||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||
<% if @categories.any? %>
|
||||
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||
<p><%= t(".categories") %></p>
|
||||
<span class="text-gray-400">·</span>
|
||||
<p><%= @categories.count %></p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<% if @categories.incomes.any? %>
|
||||
<%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %>
|
||||
<% end %>
|
||||
|
||||
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
<%= render partial: @categories, spacer_template: "categories/ruler" %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @categories.expenses.any? %>
|
||||
<%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
||||
<%= link_to new_category_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||
<p class="text-sm text-gray-500 mb-4"><%= t(".empty") %></p>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--primary" %>
|
||||
|
||||
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<%= modal_form_wrapper title: t(".new_category") do %>
|
||||
<%= render "form", category: @category %>
|
||||
<%= render "form", category: @category, categories: @categories %>
|
||||
<% end %>
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
<% if category.subcategory? %>
|
||||
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400" %>
|
||||
<% end %>
|
||||
<%= render partial: "categories/badge", locals: { category: category } %>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -10,22 +10,27 @@
|
||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
<%= t(".no_categories") %>
|
||||
</div>
|
||||
<% @categories.each do |category| %>
|
||||
<%= render partial: "category/dropdowns/row", locals: { category: } %>
|
||||
<% if @categories.any? %>
|
||||
<% Category::Group.for(@categories).each do |group| %>
|
||||
<%= render "category/dropdowns/row", category: group.category %>
|
||||
|
||||
<% group.subcategories.each do |category| %>
|
||||
<%= render "category/dropdowns/row", category: category %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||
<p class="text-sm text-gray-500 font-normal mb-4"><%= t(".empty") %></p>
|
||||
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--outline", data: { turbo_frame: :_top } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<%= link_to new_category_path(transaction_id: @transaction),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".add_new") %>
|
||||
<% end %>
|
||||
|
||||
<% if @transaction.category %>
|
||||
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry),
|
||||
<%= button_to account_transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||
@@ -35,6 +40,34 @@
|
||||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% unless @transaction.transfer? %>
|
||||
<%= link_to new_account_transaction_transfer_match_path(@transaction.entry),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
|
||||
|
||||
<p>Match transfer/payment</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form_with url: account_transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.hidden_field "account_entry[excluded]", value: !@transaction.entry.excluded %>
|
||||
<%= f.check_box "account_entry[excluded]",
|
||||
checked: @transaction.entry.excluded,
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
|
||||
|
||||
<%= lucide_icon "asterisk", class: "w-5 h-5 shrink-0 text-orange-500 ml-auto" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
<li>
|
||||
<%= sidebar_link_to t(".transactions"), transactions_path, icon: "credit-card" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= sidebar_link_to t(".budgeting"), budgets_path, icon: "map" %>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="flex flex-col mt-6">
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<%= combobox_style_tag %>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||
|
||||
<meta name="viewport"
|
||||
|
||||
23
app/views/layouts/wizard.html.erb
Normal file
23
app/views/layouts/wizard.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<%= content_for :content do %>
|
||||
<div class="flex flex-col h-dvh">
|
||||
<header class="flex items-center justify-between p-8">
|
||||
<%= link_to content_for(:previous_path) || root_path do %>
|
||||
<%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %>
|
||||
<% end %>
|
||||
|
||||
<nav>
|
||||
<%= yield :header_nav %>
|
||||
</nav>
|
||||
|
||||
<%= link_to content_for(:cancel_path) || root_path do %>
|
||||
<%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow px-8 pt-12 pb-32 overflow-y-auto">
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render template: "layouts/application" %>
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
<div class="flex justify-between items-center p-4 bg-white">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
|
||||
<% if merchant.icon_url %>
|
||||
<div class="w-8 h-8 rounded-full flex justify-center items-center">
|
||||
<%= image_tag merchant.icon_url, class: "w-8 h-8 rounded-full" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
|
||||
<% end %>
|
||||
|
||||
<p class="text-gray-900 text-sm truncate">
|
||||
<%= merchant.name %>
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user