Compare commits
116 Commits
v0.2.0-alp
...
v0.3.0
| 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 | ||
|
|
bac2e64c19 | ||
|
|
4866a4f8e4 | ||
|
|
027c18297b | ||
|
|
800eb4c146 | ||
|
|
b2a56aefc1 | ||
|
|
46131fb496 | ||
|
|
49c353e10c | ||
|
|
a59ca5b7c6 | ||
|
|
ee79016e2a | ||
|
|
13cf4d70df | ||
|
|
48e306a614 | ||
|
|
a9daba16c1 | ||
|
|
2cba5177ba | ||
|
|
13bec4599f | ||
|
|
565103caf3 | ||
|
|
c456950de8 | ||
|
|
9ec94cd1fa | ||
|
|
d73e7eacce | ||
|
|
890638e06d | ||
|
|
14fd5913fe | ||
|
|
e026f68895 | ||
|
|
1b8064b9fd | ||
|
|
d592495be5 | ||
|
|
c3248cd796 | ||
|
|
76f2714006 | ||
|
|
a9b61a655b | ||
|
|
955f211fe0 | ||
|
|
570a0c7ff6 | ||
|
|
de9ffa7ca0 | ||
|
|
b5666ad7a9 | ||
|
|
fc603a1733 | ||
|
|
6c503e4d26 | ||
|
|
57a87f2850 | ||
|
|
84f069448a | ||
|
|
25e9bd4c60 | ||
|
|
a4adfed82b | ||
|
|
03e92e63a5 | ||
|
|
c1034e6edf | ||
|
|
1c2f075053 | ||
|
|
571fc4db75 | ||
|
|
c8302a6d49 | ||
|
|
c309c8abf8 | ||
|
|
242eb5cea1 | ||
|
|
6996a225ba | ||
|
|
e641cfccd4 | ||
|
|
d1b506d16c | ||
|
|
81d604f3d4 | ||
|
|
fcb95207d7 | ||
|
|
743e291d56 | ||
|
|
6105f822b7 | ||
|
|
9cc9f42bdc | ||
|
|
8b672c4062 | ||
|
|
8befb8a8b0 | ||
|
|
f15875560e | ||
|
|
951a29d923 | ||
|
|
91eedfbd1b | ||
|
|
0af5faaa9f | ||
|
|
69f6d7f8ea | ||
|
|
cbba2ba675 | ||
|
|
3bc9da4105 | ||
|
|
9522a191de | ||
|
|
ed87023c0f | ||
|
|
3d7a74862d | ||
|
|
fc3695dda9 | ||
|
|
278d04a73a | ||
|
|
31ecd3ccd4 | ||
|
|
3ef67faf7e | ||
|
|
8ba04b0330 |
10
.env.example
10
.env.example
@@ -110,4 +110,12 @@ GITHUB_REPO_BRANCH=main
|
||||
#
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Plaid Configuration
|
||||
# ======================================================================================================
|
||||
#
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET=
|
||||
PLAID_ENV=
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -21,7 +21,7 @@ Steps to reproduce the behavior:
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**What version of Maybe are you using?**
|
||||
This could be "Hosted" (i.e. app.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
|
||||
This could be "Hosted" (i.e. app.maybefinance.com) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
|
||||
|
||||
**What operating system and browser are you using?**
|
||||
The more info the better.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
# Ignore bundler config.
|
||||
/.bundle
|
||||
/vendor/bundle
|
||||
|
||||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -37,6 +37,7 @@ gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "jwt"
|
||||
gem "faraday"
|
||||
gem "faraday-retry"
|
||||
gem "faraday-multipart"
|
||||
@@ -49,7 +50,7 @@ gem "csv"
|
||||
gem "redcarpet"
|
||||
gem "stripe"
|
||||
gem "intercom-rails"
|
||||
gem "holidays"
|
||||
gem "plaid"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
|
||||
290
Gemfile.lock
290
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,24 +83,24 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.992.0)
|
||||
aws-sdk-core (3.210.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)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.95.0)
|
||||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.169.0)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.0)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.3.0)
|
||||
benchmark (0.4.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
@@ -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,42 +127,42 @@ 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.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
faraday (2.12.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.3.0)
|
||||
net-http
|
||||
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)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
@@ -176,7 +176,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.4.2)
|
||||
good_job (4.7.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -185,11 +185,10 @@ GEM
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.1)
|
||||
highline (3.0.1)
|
||||
holidays (8.8.0)
|
||||
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)
|
||||
@@ -209,21 +208,24 @@ 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)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.1)
|
||||
jwt (~> 2.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.3)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
json (2.9.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.1)
|
||||
addressable (~> 2.8)
|
||||
@@ -233,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.1)
|
||||
loofah (2.23.1)
|
||||
logger (1.6.5)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -246,14 +248,15 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.1)
|
||||
mocha (2.5.0)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.0)
|
||||
net-imap (0.5.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -263,77 +266,83 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
nokogiri (1.18.1)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-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.1.1)
|
||||
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)
|
||||
prism (1.2.0)
|
||||
plaid (34.0.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.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.1.2)
|
||||
psych (5.2.2)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.3)
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
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.0)
|
||||
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.0)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
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)
|
||||
@@ -344,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.7.0)
|
||||
rdoc (6.10.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.10)
|
||||
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)
|
||||
@@ -381,13 +390,13 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.20.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.21)
|
||||
ruby-lsp (>= 0.20.0, < 0.21.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)
|
||||
@@ -397,17 +406,17 @@ GEM
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.26.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.21.0)
|
||||
sentry-rails (5.22.1)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.21.0)
|
||||
sentry-ruby (5.21.0)
|
||||
sentry-ruby (~> 5.22.1)
|
||||
sentry-ruby (5.22.1)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -417,33 +426,33 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11618)
|
||||
sorbet-runtime (0.5.11751)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
stripe (13.1.0)
|
||||
tailwindcss-rails (3.0.0)
|
||||
stringio (3.1.2)
|
||||
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.1)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
web-console (4.2.1)
|
||||
@@ -487,7 +496,6 @@ DEPENDENCIES
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
good_job
|
||||
holidays
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
@@ -495,12 +503,14 @@ DEPENDENCIES
|
||||
importmap-rails
|
||||
inline_svg
|
||||
intercom-rails
|
||||
jwt
|
||||
letter_opener
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
pagy
|
||||
pg (~> 1.5)
|
||||
plaid
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 7.2.2)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bin/rails tailwindcss:watch
|
||||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bundle exec bin/rails tailwindcss:watch
|
||||
worker: bundle exec good_job start
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Maybe: The OS for your personal finances
|
||||
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
|
||||
_If you're looking for the previous React codebase, you can find it
|
||||
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
||||
|
||||
10
app/assets/images/placeholder-graph.svg
Normal file
10
app/assets/images/placeholder-graph.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="944" height="201" viewBox="0 0 944 201" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 56.5502L14.4845 52.101L28.9689 50.1276L43.4534 51.7926L57.9379 40.2042L72.4224 35.6995L86.9068 35.0612L101.391 51.2218L115.876 73.6398L130.36 65.7562L144.845 64.7572L159.329 78.5795L173.814 81.9833L188.298 71.3186L202.783 80.5112L217.267 86L231.752 84.5697L246.236 83.0772L260.721 78.4002L275.205 77.343L289.689 71.8152L304.174 52.25L318.658 51.5349L333.143 48.185L347.627 47.2522L362.112 45.4586L376.596 49.2356L391.081 47.5566L405.565 31.0549L420.05 28.5641L434.534 36.6352H449.019L463.503 42.7572L477.988 37.7564L492.472 42.3467L506.957 49.3852L521.441 59.4839L535.925 52.7514L550.41 47.1535L564.894 58.6703L579.379 49.8343L593.863 50.5123H608.348L622.832 54.192L637.317 58.4763L651.801 57.2522L666.286 59.3943L677.01 62.8533L688.553 59.3943L709.129 67.4827L724.224 60.8386L738.708 52.27L753.193 58.6965L767.677 37.887L782.162 28.3178L796.646 16.383L811.13 20.9733L825.615 10.2626L840.099 11.7927L854.584 6.59032L869.068 15.771L883.553 8.12043L898.037 6.59032L912.522 2L927.006 14.8529L944 15.771" stroke="#0B0B0B" stroke-opacity="0.25" stroke-width="2" stroke-miterlimit="16"/>
|
||||
<path d="M14.4845 52.5538L0 57.0432V201H944V15.8954L927.006 14.9691L912.522 2L898.037 6.63181L883.553 8.17575L869.068 15.8954L854.584 6.63181L840.099 11.8812L825.615 10.3373L811.13 21.1448L796.646 16.513L782.161 28.5557L767.677 38.2114L753.193 59.2089L738.708 52.7244L724.224 61.3704L709.129 68.0745L688.553 59.9131L677.01 63.4034L666.286 59.9131L651.801 57.7516L637.317 58.9868L622.832 54.6637L608.348 50.9508H593.863L579.379 50.2667L564.894 59.1826L550.41 47.5616L535.925 53.2102L521.441 60.0035L506.957 49.8135L492.472 42.7114L477.988 38.0796L463.503 43.1256L449.019 36.9483H434.534L420.05 28.8042L405.565 31.3175L391.081 47.9684L376.596 49.6626L362.112 45.8514L347.627 47.6612L333.143 48.6024L318.658 51.9826L304.174 52.7042L289.689 72.4463L275.205 78.024L260.721 79.0908L246.236 83.8101L231.752 85.3161L217.267 86.7593L202.783 81.2209L188.298 71.9451L173.814 82.7063L159.329 79.2716L144.845 65.3245L130.36 66.3325L115.876 74.2874L101.391 51.6667L86.9068 35.3601L72.4224 36.0041L57.9379 40.5496L43.4534 52.2427L28.9689 50.5627L14.4845 52.5538Z" fill="url(#paint0_linear_4023_1299)" fill-opacity="0.5"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4023_1299" x1="445.5" y1="174.496" x2="445.5" y2="51.9672" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E5E5E5" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -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");
|
||||
}
|
||||
@@ -101,7 +114,7 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500;
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@@ -113,7 +126,7 @@
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@@ -162,4 +175,20 @@
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
@@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
render entryable_view_path(:show)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
redirect_to account_url(@entry.account), notice: t(".success")
|
||||
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entryable_view_path(action)
|
||||
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
|
||||
def entries_scope
|
||||
scope = Current.family.entries
|
||||
scope = scope.where(account: @account) if @account
|
||||
scope
|
||||
end
|
||||
|
||||
def search_params
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@holdings = @account.holdings.current
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -13,16 +12,17 @@ class Account::HoldingsController < ApplicationController
|
||||
|
||||
def destroy
|
||||
@holding.destroy_holding_and_entries!
|
||||
redirect_back_or_to account_holdings_path(@account)
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@holding.account) }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_holding
|
||||
@holding = @account.holdings.current.find(params[:id])
|
||||
@holding = Current.family.holdings.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,66 +1,37 @@
|
||||
class Account::TradesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_trades.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
|
||||
end
|
||||
|
||||
def create
|
||||
@builder = Account::EntryBuilder.new(entry_params)
|
||||
|
||||
if entry = @builder.save
|
||||
entry.sync_account_later
|
||||
redirect_to @account, notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_back_or_to @account
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def securities
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security::SynthComboboxOption.find_in_synth(query)
|
||||
end
|
||||
permitted_entryable_attributes :id, :qty, :price
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
def build_entry
|
||||
Account::TradeBuilder.new(create_entry_params)
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
end
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:qty,
|
||||
:ticker,
|
||||
:price
|
||||
]
|
||||
)
|
||||
.merge(account: @account)
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "Account::Trade")
|
||||
|
||||
qty = update_params[:entryable_attributes][:qty]
|
||||
price = update_params[:entryable_attributes][:price]
|
||||
|
||||
if qty.present? && price.present?
|
||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||
update_params[:entryable_attributes][:qty] = qty
|
||||
update_params[:amount] = qty * price.to_d
|
||||
end
|
||||
|
||||
update_params.except(:nature)
|
||||
end
|
||||
end
|
||||
|
||||
22
app/controllers/account/transaction_categories_controller.rb
Normal file
22
app/controllers/account/transaction_categories_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Account::TransactionCategoriesController < ApplicationController
|
||||
def update
|
||||
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_transaction_path(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"category_menu_account_transaction_#{@entry.account_transaction_id}",
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: @entry.account_transaction }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||
end
|
||||
end
|
||||
@@ -1,74 +1,37 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
|
||||
def index
|
||||
@pagy, @entries = pagy(
|
||||
@account.entries.account_transactions.reverse_chronological,
|
||||
limit: params[:per_page] || "10"
|
||||
)
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def update
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
@entry.update!(entry_params.except(:origin))
|
||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
@entry,
|
||||
partial: "account/entries/entry",
|
||||
locals: entry_locals.merge(entry: @entry)
|
||||
)
|
||||
end
|
||||
end
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
private
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def entry_locals
|
||||
{
|
||||
selectable: entry_params[:origin].present?,
|
||||
show_balance: entry_params[:origin] == "account",
|
||||
origin: entry_params[:origin]
|
||||
}
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:category_id,
|
||||
:merchant_id,
|
||||
{ tag_ids: [] }
|
||||
]
|
||||
).tap do |permitted_params|
|
||||
nature = permitted_params.delete(:nature)
|
||||
|
||||
if permitted_params[:amount]
|
||||
amount_value = permitted_params[:amount].to_d
|
||||
|
||||
if nature == "income"
|
||||
amount_value *= -1
|
||||
end
|
||||
|
||||
permitted_params[:amount] = amount_value
|
||||
end
|
||||
end
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
end
|
||||
|
||||
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,62 +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,
|
||||
currency: transfer_params[:currency]
|
||||
|
||||
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
|
||||
@@ -1,35 +1,3 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to @account
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
include EntryableResource
|
||||
end
|
||||
|
||||
@@ -4,8 +4,8 @@ class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync]
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@accounts = Current.family.accounts.ungrouped.alphabetically
|
||||
@manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically
|
||||
@plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered
|
||||
end
|
||||
|
||||
def summary
|
||||
@@ -14,7 +14,7 @@ class AccountsController < ApplicationController
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@accounts = Current.family.accounts
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
end
|
||||
|
||||
@@ -27,11 +27,21 @@ class AccountsController < ApplicationController
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
end
|
||||
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def chart
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def sync_all
|
||||
Current.family.accounts.active.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
unless Current.family.syncing?
|
||||
Current.family.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -4,6 +4,8 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
return false if self_hosted?
|
||||
@@ -24,4 +26,16 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
"with_sidebar"
|
||||
end
|
||||
|
||||
def detect_os
|
||||
user_agent = request.user_agent
|
||||
@os = case user_agent
|
||||
when /Windows/i then "windows"
|
||||
when /Macintosh/i then "mac"
|
||||
when /Linux/i then "linux"
|
||||
when /Android/i then "android"
|
||||
when /iPhone|iPad/i then "ios"
|
||||
else ""
|
||||
end
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ module AccountableResource
|
||||
included do
|
||||
layout :with_sidebar
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -16,8 +17,7 @@ module AccountableResource
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: accountable_type.new,
|
||||
institution_id: params[:institution_id]
|
||||
accountable: accountable_type.new
|
||||
)
|
||||
end
|
||||
|
||||
@@ -29,20 +29,35 @@ module AccountableResource
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t(".success")
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
redirect_back_or_to @account, notice: t(".success")
|
||||
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
private
|
||||
def set_link_token
|
||||
@link_token = Current.family.get_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name
|
||||
)
|
||||
end
|
||||
|
||||
def webhooks_url
|
||||
return webhooks_plaid_url if Rails.env.production?
|
||||
|
||||
base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/"))
|
||||
base_url + "/webhooks/plaid"
|
||||
end
|
||||
|
||||
def accountable_type
|
||||
controller_name.classify.constantize
|
||||
end
|
||||
@@ -53,7 +68,7 @@ module AccountableResource
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(
|
||||
:name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to,
|
||||
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
accountable_attributes: self.class.permitted_accountable_attributes
|
||||
)
|
||||
end
|
||||
|
||||
@@ -2,12 +2,20 @@ module AutoSync
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? }
|
||||
before_action :sync_family, if: :family_needs_auto_sync?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_family
|
||||
Current.family.sync
|
||||
Current.family.update!(last_synced_at: Time.current)
|
||||
Current.family.sync_later
|
||||
end
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
return false unless Current.family.accounts.any?
|
||||
|
||||
Current.family.last_synced_at.blank? ||
|
||||
Current.family.last_synced_at.to_date < Date.current
|
||||
end
|
||||
end
|
||||
|
||||
129
app/controllers/concerns/entryable_resource.rb
Normal file
129
app/controllers/concerns/entryable_resource.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
module EntryableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
layout :with_sidebar
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def permitted_entryable_attributes(*attrs)
|
||||
@permitted_entryable_attributes = attrs if attrs.any?
|
||||
@permitted_entryable_attributes ||= [ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
|
||||
@entry = Current.family.entries.new(
|
||||
account: account,
|
||||
currency: account ? account.currency : Current.family.currency,
|
||||
entryable: entryable_type.new
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
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 }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
account = @entry.account
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.destroy.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entryable_type
|
||||
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
|
||||
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
|
||||
klass.constantize if permitted_entryable_types.include?(klass)
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def build_entry
|
||||
Current.family.entries.new(create_entry_params)
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
prepared_entry_params
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
prepared_entry_params.merge({
|
||||
entryable_type: entryable_type.name,
|
||||
entryable_attributes: entry_params[:entryable_attributes] || {}
|
||||
})
|
||||
end
|
||||
|
||||
def prepared_entry_params
|
||||
default_params = entry_params.except(:nature)
|
||||
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
|
||||
|
||||
if entry_params[:nature].present? && entry_params[:amount].present?
|
||||
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||
default_params = default_params.merge(amount: signed_amount)
|
||||
end
|
||||
|
||||
default_params
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ module Localize
|
||||
|
||||
included do
|
||||
around_action :switch_locale
|
||||
around_action :switch_timezone
|
||||
end
|
||||
|
||||
private
|
||||
@@ -10,4 +11,9 @@ module Localize
|
||||
locale = Current.family.try(:locale) || I18n.default_locale
|
||||
I18n.with_locale(locale, &action)
|
||||
end
|
||||
|
||||
def switch_timezone(&action)
|
||||
timezone = Current.family.try(:timezone) || Time.zone
|
||||
Time.use_zone(timezone, &action)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ module StoreLocation
|
||||
helper_method :previous_path
|
||||
before_action :store_return_to
|
||||
after_action :clear_previous_path
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
||||
end
|
||||
|
||||
def previous_path
|
||||
@@ -12,6 +14,14 @@ module StoreLocation
|
||||
end
|
||||
|
||||
private
|
||||
def handle_not_found
|
||||
if request.fullpath == session[:return_to]
|
||||
session.delete(:return_to)
|
||||
redirect_to fallback_path
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def store_return_to
|
||||
if params[:return_to].present?
|
||||
|
||||
@@ -32,7 +32,7 @@ class Import::UploadsController < ApplicationController
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true)
|
||||
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
|
||||
return false if csv.headers.empty?
|
||||
return false if csv.count == 0
|
||||
true
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
class InstitutionsController < ApplicationController
|
||||
before_action :set_institution, except: %i[new create]
|
||||
|
||||
def new
|
||||
@institution = Institution.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.institutions.create!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@institution.update!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@institution.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
@institution.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def institution_params
|
||||
params.require(:institution).permit(:name, :logo)
|
||||
end
|
||||
|
||||
def set_institution
|
||||
@institution = Current.family.institutions.find(params[:id])
|
||||
end
|
||||
end
|
||||
38
app/controllers/plaid_items_controller.rb
Normal file
38
app/controllers/plaid_items_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
class PlaidItemsController < ApplicationController
|
||||
before_action :set_plaid_item, only: %i[destroy sync]
|
||||
|
||||
def create
|
||||
Current.family.plaid_items.create_from_public_token(
|
||||
plaid_item_params[:public_token],
|
||||
item_name: item_name,
|
||||
)
|
||||
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@plaid_item.destroy_later
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @plaid_item.syncing?
|
||||
@plaid_item.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
def set_plaid_item
|
||||
@plaid_item = Current.family.plaid_items.find(params[:id])
|
||||
end
|
||||
|
||||
def plaid_item_params
|
||||
params.require(:plaid_item).permit(:public_token, metadata: {})
|
||||
end
|
||||
|
||||
def item_name
|
||||
plaid_item_params.dig(:metadata, :institution, :name)
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,7 @@ class PropertiesController < ApplicationController
|
||||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
),
|
||||
institution_id: params[:institution_id]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -24,11 +24,10 @@ 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
|
||||
render :new, status: :unprocessable_entity
|
||||
render :new, status: :unprocessable_entity, alert: t(".failure")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
class SecuritiesController < ApplicationController
|
||||
def import
|
||||
SecuritiesImportJob.perform_later(params[:exchange_mic])
|
||||
def index
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security.search({
|
||||
search: query,
|
||||
country: country_code_filter
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
def country_code_filter
|
||||
filter = params[:country_code]
|
||||
filter = "#{filter},US" unless filter == "US"
|
||||
filter
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,104 +3,28 @@ 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
|
||||
|
||||
def new
|
||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
||||
if params[:account_id]
|
||||
e.account = Current.family.accounts.find(params[:account_id])
|
||||
e.currency = e.account.currency
|
||||
else
|
||||
e.currency = Current.family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = Current.family
|
||||
.accounts
|
||||
.find(params[:account_entry][:account_id])
|
||||
.entries
|
||||
.create!(transaction_entry_params.merge(amount: amount))
|
||||
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to @entry.account, notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
transaction_entry_params[:amount].to_d * -1
|
||||
else
|
||||
transaction_entry_params[:amount].to_d
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
|
||||
def transaction_entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
end
|
||||
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, :id ]
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
class WebhooksController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, only: [ :stripe ]
|
||||
skip_before_action :verify_authenticity_token
|
||||
skip_authentication
|
||||
|
||||
def plaid
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
Provider::Plaid.process_webhook(webhook_body)
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
|
||||
end
|
||||
|
||||
def stripe
|
||||
webhook_body = request.body.read
|
||||
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
||||
|
||||
@@ -3,28 +3,30 @@ 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
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true, totals: false)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
# Valuations always go first, then sort by created_at desc
|
||||
sorted_entries = grouped_entries.sort_by do |entry|
|
||||
[ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ]
|
||||
end
|
||||
|
||||
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield sorted_entries
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
|
||||
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,13 +1,13 @@
|
||||
module Account::CashesHelper
|
||||
def brokerage_cash(account)
|
||||
module Account::HoldingsHelper
|
||||
def brokerage_cash_holding(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.balance,
|
||||
qty: account.cash_balance,
|
||||
price: 1,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
amount: account.cash_balance,
|
||||
currency: currency.iso_code,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module Account::TransfersHelper
|
||||
end
|
||||
@@ -4,6 +4,7 @@ module ApplicationHelper
|
||||
def date_format_options
|
||||
[
|
||||
[ "DD-MM-YYYY", "%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" ],
|
||||
@@ -14,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
|
||||
@@ -61,14 +72,14 @@ module ApplicationHelper
|
||||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def drawer(&block)
|
||||
def drawer(reload_on_close: false, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
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 = {})
|
||||
@@ -157,4 +168,32 @@ module ApplicationHelper
|
||||
.map { |_currency, money| format_money(money) }
|
||||
.join(separator)
|
||||
end
|
||||
|
||||
def show_super_admin_bar?
|
||||
if params[:admin].present?
|
||||
cookies.permanent[:admin] = params[:admin]
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ module FormsHelper
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
|
||||
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ], [ "All", "all" ] ]
|
||||
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ]
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module InstitutionsHelper
|
||||
def institution_logo(institution)
|
||||
institution.logo.attached? ? institution.logo : institution.logo_url
|
||||
end
|
||||
end
|
||||
@@ -363,4 +363,8 @@ module LanguagesHelper
|
||||
end
|
||||
.sort_by { |label, locale| label }
|
||||
end
|
||||
|
||||
def timezone_options
|
||||
ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails";
|
||||
import "controllers";
|
||||
|
||||
Turbo.StreamActions.redirect = function () {
|
||||
Turbo.visit(this.target);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const application = Application.start();
|
||||
application.debug = false;
|
||||
window.Stimulus = application;
|
||||
|
||||
Turbo.setConfirmMethod((message) => {
|
||||
Turbo.config.forms.confirm = (message) => {
|
||||
const dialog = document.getElementById("turbo-confirm");
|
||||
|
||||
try {
|
||||
@@ -37,11 +37,21 @@ Turbo.setConfirmMethod((message) => {
|
||||
dialog.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
resolve(dialog.returnValue === "confirm");
|
||||
const confirmed = dialog.returnValue === "confirm";
|
||||
|
||||
if (!confirmed) {
|
||||
document.getElementById("turbo-confirm-title").innerHTML =
|
||||
"Are you sure?";
|
||||
document.getElementById("turbo-confirm-body").innerHTML =
|
||||
"You will not be able to undo this decision";
|
||||
document.getElementById("turbo-confirm-accept").innerHTML = "Confirm";
|
||||
}
|
||||
|
||||
resolve(confirmed);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export { application };
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="modal"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
reloadOnClose: { type: Boolean, default: false },
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (this.element.open) return;
|
||||
this.element.showModal();
|
||||
@@ -10,11 +14,15 @@ export default class extends Controller {
|
||||
// Hide the dialog when the user clicks outside of it
|
||||
clickOutside(e) {
|
||||
if (e.target === this.element) {
|
||||
this.element.close();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.close();
|
||||
|
||||
if (this.reloadOnCloseValue) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
app/javascript/controllers/plaid_controller.js
Normal file
54
app/javascript/controllers/plaid_controller.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="plaid"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
linkToken: String,
|
||||
};
|
||||
|
||||
open() {
|
||||
const handler = Plaid.create({
|
||||
token: this.linkTokenValue,
|
||||
onSuccess: this.handleSuccess,
|
||||
onLoad: this.handleLoad,
|
||||
onExit: this.handleExit,
|
||||
onEvent: this.handleEvent,
|
||||
});
|
||||
|
||||
handler.open();
|
||||
}
|
||||
|
||||
handleSuccess(public_token, metadata) {
|
||||
window.location.href = "/accounts";
|
||||
|
||||
fetch("/plaid_items", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plaid_item: {
|
||||
public_token: public_token,
|
||||
metadata: metadata,
|
||||
},
|
||||
}),
|
||||
}).then((response) => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleExit(err, metadata) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
handleEvent(eventName, metadata) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
handleLoad() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -51,17 +51,12 @@ export default class extends Controller {
|
||||
_normalizeDataPoints() {
|
||||
this._normalDataPoints = (this.dataValue.values || []).map((d) => ({
|
||||
...d,
|
||||
date: this._parseDate(d.date),
|
||||
date: new Date(`${d.date}T00:00:00Z`),
|
||||
value: d.value.amount ? +d.value.amount : +d.value,
|
||||
currency: d.value.currency,
|
||||
}));
|
||||
}
|
||||
|
||||
_parseDate(dateString) {
|
||||
const [year, month, day] = dateString.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
_rememberInitialContainerSize() {
|
||||
this._d3InitialContainerWidth = this._d3Container.node().clientWidth;
|
||||
this._d3InitialContainerHeight = this._d3Container.node().clientHeight;
|
||||
@@ -188,7 +183,7 @@ export default class extends Controller {
|
||||
this._normalDataPoints[this._normalDataPoints.length - 1].date,
|
||||
])
|
||||
.tickSize(0)
|
||||
.tickFormat(d3.timeFormat("%d %b %Y")),
|
||||
.tickFormat(d3.utcFormat("%d %b %Y")),
|
||||
)
|
||||
.select(".domain")
|
||||
.remove();
|
||||
@@ -367,7 +362,7 @@ export default class extends Controller {
|
||||
_tooltipTemplate(datum) {
|
||||
return `
|
||||
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
|
||||
${d3.timeFormat("%b %d, %Y")(datum.date)}
|
||||
${d3.utcFormat("%b %d, %Y")(datum.date)}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px;">
|
||||
@@ -404,8 +399,14 @@ export default class extends Controller {
|
||||
|
||||
_tooltipTrendColor(datum) {
|
||||
return {
|
||||
up: tailwindColors.success,
|
||||
down: tailwindColors.error,
|
||||
up:
|
||||
datum.trend.favorable_direction === "up"
|
||||
? tailwindColors.success
|
||||
: tailwindColors.error,
|
||||
down:
|
||||
datum.trend.favorable_direction === "down"
|
||||
? tailwindColors.success
|
||||
: tailwindColors.error,
|
||||
flat: tailwindColors.gray[500],
|
||||
}[datum.trend.direction];
|
||||
}
|
||||
|
||||
@@ -1,71 +1,11 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
const TRADE_TYPES = {
|
||||
BUY: "buy",
|
||||
SELL: "sell",
|
||||
TRANSFER_IN: "transfer_in",
|
||||
TRANSFER_OUT: "transfer_out",
|
||||
INTEREST: "interest",
|
||||
};
|
||||
|
||||
const FIELD_VISIBILITY = {
|
||||
[TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true },
|
||||
[TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true },
|
||||
[TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true },
|
||||
[TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true },
|
||||
[TRADE_TYPES.INTEREST]: { amount: true },
|
||||
};
|
||||
|
||||
// Connects to data-controller="trade-form"
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"typeInput",
|
||||
"tickerInput",
|
||||
"amountInput",
|
||||
"transferAccountInput",
|
||||
"qtyInput",
|
||||
"priceInput",
|
||||
];
|
||||
|
||||
connect() {
|
||||
this.handleTypeChange = this.handleTypeChange.bind(this);
|
||||
this.typeInputTarget.addEventListener("change", this.handleTypeChange);
|
||||
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.typeInputTarget.removeEventListener("change", this.handleTypeChange);
|
||||
}
|
||||
|
||||
handleTypeChange(event) {
|
||||
this.updateFields(event.target.value);
|
||||
}
|
||||
|
||||
updateFields(type) {
|
||||
const visibleFields = FIELD_VISIBILITY[type] || {};
|
||||
|
||||
Object.entries(this.fieldTargets).forEach(([field, target]) => {
|
||||
const isVisible = visibleFields[field] || false;
|
||||
|
||||
// Update visibility
|
||||
target.hidden = !isVisible;
|
||||
|
||||
// Update required status based on visibility
|
||||
if (isVisible) {
|
||||
target.setAttribute("required", "");
|
||||
} else {
|
||||
target.removeAttribute("required");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get fieldTargets() {
|
||||
return {
|
||||
ticker: this.tickerInputTarget,
|
||||
amount: this.amountInputTarget,
|
||||
transferAccount: this.transferAccountInputTarget,
|
||||
qty: this.qtyInputTarget,
|
||||
price: this.priceInputTarget,
|
||||
};
|
||||
// Reloads the page with a new type without closing the modal
|
||||
async changeType(event) {
|
||||
const url = new URL(event.params.url, window.location.origin);
|
||||
url.searchParams.set(event.params.key, event.target.value);
|
||||
Turbo.visit(url, { frame: "modal" });
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
class AccountSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account, start_date: nil)
|
||||
account.sync(start_date: start_date)
|
||||
end
|
||||
end
|
||||
7
app/jobs/destroy_job.rb
Normal file
7
app/jobs/destroy_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class DestroyJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(model)
|
||||
model.destroy
|
||||
end
|
||||
end
|
||||
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
|
||||
7
app/jobs/sync_job.rb
Normal file
7
app/jobs/sync_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class SyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(sync)
|
||||
sync.perform
|
||||
end
|
||||
end
|
||||
@@ -4,28 +4,27 @@ class Account < ApplicationRecord
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
belongs_to :import, optional: true
|
||||
belongs_to :plaid_account, optional: true
|
||||
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :syncs, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
monetize :balance, :cash_balance
|
||||
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :active, -> { where(is_active: true, scheduled_for_deletion: false) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :ungrouped, -> { where(institution_id: nil) }
|
||||
scope :manual, -> { where(plaid_account_id: nil) }
|
||||
|
||||
has_one_attached :logo
|
||||
|
||||
@@ -33,8 +32,6 @@ class Account < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :accountable, update_only: true
|
||||
|
||||
delegate :value, :series, to: :accountable
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
@@ -60,7 +57,7 @@ class Account < ApplicationRecord
|
||||
|
||||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes)
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
@@ -87,22 +84,56 @@ class Account < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
Syncer.new(self, start_date: start_date).run
|
||||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(family, target: "syncing-notice")
|
||||
resolve_stale_issues
|
||||
accountable.post_sync
|
||||
end
|
||||
|
||||
def series(period: Period.last_30_days, currency: nil)
|
||||
balance_series = balances.in_period(period).where(currency: currency || self.currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down")
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def original_balance
|
||||
balance_amount = balances.chronological.first&.balance || balance
|
||||
Money.new(balance_amount, currency)
|
||||
end
|
||||
|
||||
def owns_ticker?(ticker)
|
||||
security_id = Security.find_by(ticker: ticker)&.id
|
||||
entries.account_trades
|
||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||
.where(account_trades: { security_id: security_id }).any?
|
||||
def current_holdings
|
||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||
end
|
||||
|
||||
def favorable_direction
|
||||
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)
|
||||
@@ -120,17 +151,10 @@ class Account < ApplicationRecord
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def holding_qty(security, date: Date.current)
|
||||
entries.account_trades
|
||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||
.where(account_trades: { security_id: security.id })
|
||||
.where("account_entries.date <= ?", date)
|
||||
.sum("account_trades.qty")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
class Account::Balance::Calculator
|
||||
def initialize(account, sync_start_date)
|
||||
@account = account
|
||||
@sync_start_date = sync_start_date
|
||||
end
|
||||
|
||||
def calculate(is_partial_sync: false)
|
||||
cached_entries = account.entries.where("date >= ?", sync_start_date).to_a
|
||||
sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries)
|
||||
|
||||
prior_balance = sync_starting_balance
|
||||
|
||||
(sync_start_date..Date.current).map do |date|
|
||||
current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
build_balance(date, current_balance)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :sync_start_date
|
||||
|
||||
def find_start_balance_for_partial_sync
|
||||
account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance
|
||||
end
|
||||
|
||||
def find_start_balance_for_full_sync(cached_entries)
|
||||
account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? })
|
||||
end
|
||||
|
||||
def calculate_balance_for_date(date, entries:, prior_balance:)
|
||||
valuation = entries.find { |e| e.date == date && e.account_valuation? }
|
||||
|
||||
return valuation.amount if valuation
|
||||
|
||||
entries = entries.select { |e| e.date == date }
|
||||
|
||||
prior_balance - net_entry_flows(entries)
|
||||
end
|
||||
|
||||
def net_entry_flows(entries, target_currency = account.currency)
|
||||
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
||||
|
||||
flows = converted_entry_amounts.sum(&:amount)
|
||||
|
||||
account.liability? ? flows * -1 : flows
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
account.balances.build \
|
||||
date: date,
|
||||
balance: balance,
|
||||
currency: currency || account.currency
|
||||
end
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
class Account::Balance::Converter
|
||||
def initialize(account, sync_start_date)
|
||||
@account = account
|
||||
@sync_start_date = sync_start_date
|
||||
end
|
||||
|
||||
def convert(balances)
|
||||
calculate_converted_balances(balances)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :sync_start_date
|
||||
|
||||
def calculate_converted_balances(balances)
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
if ExchangeRate.exchange_rates_provider.nil?
|
||||
account.observe_missing_exchange_rate_provider
|
||||
return []
|
||||
end
|
||||
|
||||
exchange_rates = ExchangeRate.find_rates from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: sync_start_date
|
||||
|
||||
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
|
||||
|
||||
if missing_exchange_rates.any?
|
||||
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
|
||||
return []
|
||||
end
|
||||
|
||||
balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
|
||||
end
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
account.balances.build \
|
||||
date: date,
|
||||
balance: balance,
|
||||
currency: currency || account.currency
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
class Account::Balance::Loader
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def load(balances, start_date)
|
||||
Account::Balance.transaction do
|
||||
upsert_balances!(balances)
|
||||
purge_stale_balances!(start_date)
|
||||
|
||||
account.reload
|
||||
|
||||
update_account_balance!(balances)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def update_account_balance!(balances)
|
||||
last_balance = balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
account.update! balance: last_balance if last_balance.present?
|
||||
end
|
||||
|
||||
def upsert_balances!(balances)
|
||||
current_time = Time.now
|
||||
balances_to_upsert = balances.map do |balance|
|
||||
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
||||
end
|
||||
|
||||
def purge_stale_balances!(start_date)
|
||||
account.balances.delete_by("date < ?", start_date)
|
||||
end
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
class Account::Balance::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@provided_start_date = start_date
|
||||
@sync_start_date = calculate_sync_start_date(start_date)
|
||||
@loader = Account::Balance::Loader.new(account)
|
||||
@converter = Account::Balance::Converter.new(account, sync_start_date)
|
||||
@calculator = Account::Balance::Calculator.new(account, sync_start_date)
|
||||
end
|
||||
|
||||
def run
|
||||
daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?)
|
||||
daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency
|
||||
|
||||
loader.load(daily_balances, account_start_date)
|
||||
rescue Money::ConversionError => e
|
||||
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator
|
||||
|
||||
def account_start_date
|
||||
@account_start_date ||= begin
|
||||
oldest_entry = account.entries.chronological.first
|
||||
|
||||
return Date.current unless oldest_entry.present?
|
||||
|
||||
if oldest_entry.account_valuation?
|
||||
oldest_entry.date
|
||||
else
|
||||
oldest_entry.date - 1.day
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(provided_start_date)
|
||||
return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date)
|
||||
|
||||
account_start_date
|
||||
end
|
||||
|
||||
def prior_balance_available?(date)
|
||||
account.balances.find_by(currency: account.currency, date: date - 1.day).present?
|
||||
end
|
||||
|
||||
def is_partial_sync?
|
||||
sync_start_date == provided_start_date && sync_start_date < Date.current
|
||||
end
|
||||
end
|
||||
121
app/models/account/balance_calculator.rb
Normal file
121
app/models/account/balance_calculator.rb
Normal file
@@ -0,0 +1,121 @@
|
||||
class Account::BalanceCalculator
|
||||
def initialize(account, holdings: nil)
|
||||
@account = account
|
||||
@holdings = holdings || []
|
||||
end
|
||||
|
||||
def calculate(reverse: false, start_date: nil)
|
||||
cash_balances = reverse ? reverse_cash_balances : forward_cash_balances
|
||||
|
||||
cash_balances.map do |balance|
|
||||
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
|
||||
balance.balance = balance.balance + holdings_value
|
||||
balance
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :holdings
|
||||
|
||||
def oldest_date
|
||||
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def reverse_cash_balances
|
||||
prior_balance = account.cash_balance
|
||||
|
||||
Date.current.downto(oldest_date).map do |date|
|
||||
entries_for_date = converted_entries.select { |e| e.date == date }
|
||||
holdings_for_date = converted_holdings.select { |h| h.date == date }
|
||||
|
||||
valuation = entries_for_date.find { |e| e.account_valuation? }
|
||||
|
||||
current_balance = if valuation
|
||||
# To get this to a cash valuation, we back out holdings value on day
|
||||
valuation.amount - holdings_for_date.sum(&:amount)
|
||||
else
|
||||
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
|
||||
|
||||
calculate_balance(prior_balance, transactions)
|
||||
end
|
||||
|
||||
balance_record = Account::Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: valuation ? current_balance : prior_balance,
|
||||
cash_balance: valuation ? current_balance : prior_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
balance_record
|
||||
end
|
||||
end
|
||||
|
||||
def forward_cash_balances
|
||||
prior_balance = 0
|
||||
current_balance = nil
|
||||
|
||||
oldest_date.upto(Date.current).map do |date|
|
||||
entries_for_date = converted_entries.select { |e| e.date == date }
|
||||
holdings_for_date = converted_holdings.select { |h| h.date == date }
|
||||
|
||||
valuation = entries_for_date.find { |e| e.account_valuation? }
|
||||
|
||||
current_balance = if valuation
|
||||
# To get this to a cash valuation, we back out holdings value on day
|
||||
valuation.amount - holdings_for_date.sum(&:amount)
|
||||
else
|
||||
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
|
||||
|
||||
calculate_balance(prior_balance, transactions, inverse: true)
|
||||
end
|
||||
|
||||
balance_record = Account::Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: current_balance,
|
||||
cash_balance: current_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
balance_record
|
||||
end
|
||||
end
|
||||
|
||||
def converted_entries
|
||||
@converted_entries ||= @account.entries.order(:date).to_a.map do |e|
|
||||
converted_entry = e.dup
|
||||
converted_entry.amount = converted_entry.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: e.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_entry.currency = account.currency
|
||||
converted_entry
|
||||
end
|
||||
end
|
||||
|
||||
def converted_holdings
|
||||
@converted_holdings ||= holdings.map do |h|
|
||||
converted_holding = h.dup
|
||||
converted_holding.amount = converted_holding.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: h.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_holding.currency = account.currency
|
||||
converted_holding
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_balance(prior_balance, transactions, inverse: false)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = inverse ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
end
|
||||
end
|
||||
94
app/models/account/balance_trend_calculator.rb
Normal file
94
app/models/account/balance_trend_calculator.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
# The current system calculates a single, end-of-day balance every day for each account for simplicity.
|
||||
# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances
|
||||
# to show users how each entry affects their balances. This class calculates intraday balances by
|
||||
# interpolating between end-of-day balances.
|
||||
class Account::BalanceTrendCalculator
|
||||
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
||||
|
||||
class << self
|
||||
def for(entries)
|
||||
return nil if entries.blank?
|
||||
|
||||
account = entries.first.account
|
||||
|
||||
date_range = entries.minmax_by(&:date)
|
||||
min_entry_date, max_entry_date = date_range.map(&:date)
|
||||
|
||||
# In case view is filtered and there are entry gaps, refetch all entries in range
|
||||
all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a
|
||||
balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a
|
||||
holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a
|
||||
|
||||
new(all_entries, balances, holdings)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(entries, balances, holdings)
|
||||
@entries = entries
|
||||
@balances = balances
|
||||
@holdings = holdings
|
||||
end
|
||||
|
||||
def trend_for(entry)
|
||||
intraday_balance = nil
|
||||
intraday_cash_balance = nil
|
||||
|
||||
start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency }
|
||||
end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency }
|
||||
|
||||
return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank?
|
||||
|
||||
todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount)
|
||||
|
||||
prior_balance = start_of_day_balance.balance
|
||||
prior_cash_balance = start_of_day_balance.cash_balance
|
||||
current_balance = nil
|
||||
current_cash_balance = nil
|
||||
|
||||
todays_entries = entries.select { |e| e.date == entry.date }
|
||||
|
||||
todays_entries.each_with_index do |e, idx|
|
||||
if e.account_valuation?
|
||||
current_balance = e.amount
|
||||
current_cash_balance = e.amount
|
||||
else
|
||||
multiplier = e.account.liability? ? 1 : -1
|
||||
balance_change = e.account_trade? ? 0 : multiplier * e.amount
|
||||
cash_change = multiplier * e.amount
|
||||
|
||||
current_balance = prior_balance + balance_change
|
||||
current_cash_balance = prior_cash_balance + cash_change
|
||||
end
|
||||
|
||||
if e.id == entry.id
|
||||
# Final entry should always match the end-of-day balances
|
||||
if idx == todays_entries.size - 1
|
||||
intraday_balance = end_of_day_balance.balance
|
||||
intraday_cash_balance = end_of_day_balance.cash_balance
|
||||
else
|
||||
intraday_balance = current_balance
|
||||
intraday_cash_balance = current_cash_balance
|
||||
end
|
||||
|
||||
break
|
||||
else
|
||||
prior_balance = current_balance
|
||||
prior_cash_balance = current_cash_balance
|
||||
end
|
||||
end
|
||||
|
||||
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
|
||||
|
||||
BalanceTrend.new(
|
||||
trend: TimeSeries::Trend.new(
|
||||
current: Money.new(intraday_balance, entry.currency),
|
||||
previous: Money.new(prior_balance, entry.currency),
|
||||
favorable_direction: entry.account.favorable_direction
|
||||
),
|
||||
cash: Money.new(intraday_cash_balance, entry.currency),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :entries, :balances, :holdings
|
||||
end
|
||||
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,13 +10,44 @@ 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, :created_at) }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
scope :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
|
||||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -29,65 +60,39 @@ class Account::Entry < ApplicationRecord
|
||||
}
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_entry&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
|
||||
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
|
||||
|
||||
def prior_balance
|
||||
account.balances.find_by(date: date - 1)&.balance || 0
|
||||
def balance_trend(entries, balances)
|
||||
Account::BalanceTrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
def balance_after_entry
|
||||
if account_valuation?
|
||||
Money.new(amount, currency)
|
||||
else
|
||||
new_balance = prior_balance
|
||||
entries_on_entry_date.each do |e|
|
||||
next if e.account_valuation?
|
||||
|
||||
change = e.amount
|
||||
change = account.liability? ? change : -change
|
||||
new_balance += change
|
||||
break if e == self
|
||||
end
|
||||
|
||||
Money.new(new_balance, currency)
|
||||
end
|
||||
def display_name
|
||||
enriched_name.presence || name
|
||||
end
|
||||
|
||||
def trend
|
||||
TimeSeries::Trend.new(
|
||||
current: balance_after_entry,
|
||||
previous: Money.new(prior_balance, currency),
|
||||
favorable_direction: account.favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
def entries_on_entry_date
|
||||
account.entries.where(date: date).order(created_at: :asc)
|
||||
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
|
||||
20.years.ago.to_date
|
||||
30.years.ago.to_date
|
||||
end
|
||||
|
||||
def daily_totals(entries, currency, period: Period.last_30_days)
|
||||
@@ -119,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],
|
||||
@@ -148,92 +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
|
||||
|
||||
private
|
||||
|
||||
def previous_entry
|
||||
@previous_entry ||= account
|
||||
.entries
|
||||
.where("date < ?", date)
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
class Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
|
||||
|
||||
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_builder.save
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_builder
|
||||
case type
|
||||
when "buy", "sell"
|
||||
create_trade_builder
|
||||
else
|
||||
create_transaction_builder
|
||||
end
|
||||
end
|
||||
|
||||
def create_trade_builder
|
||||
Account::TradeBuilder.new \
|
||||
type: type,
|
||||
date: date,
|
||||
qty: qty,
|
||||
ticker: ticker,
|
||||
price: price,
|
||||
account: account
|
||||
end
|
||||
|
||||
def create_transaction_builder
|
||||
Account::TransactionBuilder.new \
|
||||
type: type,
|
||||
date: date,
|
||||
amount: amount,
|
||||
account: account,
|
||||
transfer_account_id: transfer_account_id
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -9,9 +9,6 @@ class Account::Holding < ApplicationRecord
|
||||
validates :qty, :currency, presence: true
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :current, -> { where(date: Date.current).order(amount: :desc) }
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :known_value, -> { where.not(amount: nil) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
|
||||
delegate :ticker, to: :security
|
||||
@@ -22,15 +19,19 @@ class Account::Holding < ApplicationRecord
|
||||
|
||||
def weight
|
||||
return nil unless amount
|
||||
return 0 if amount.zero?
|
||||
|
||||
portfolio_value = account.holdings.current.known_value.sum(&:amount)
|
||||
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
|
||||
account.balance.zero? ? 1 : amount / account.balance * 100
|
||||
end
|
||||
|
||||
# Basic approximation of cost-basis
|
||||
def avg_cost
|
||||
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
|
||||
Money.new(avg_cost, currency)
|
||||
avg_cost = account.entries.account_trades
|
||||
.joins("INNER JOIN account_trades ON account_trades.id = account_entries.entryable_id")
|
||||
.where("account_trades.security_id = ? AND account_trades.qty > 0 AND account_entries.date <= ?", security.id, date)
|
||||
.average(:price)
|
||||
|
||||
Money.new(avg_cost || price, currency)
|
||||
end
|
||||
|
||||
def trend
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
class Account::Holding::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
|
||||
@portfolio = {}
|
||||
|
||||
load_prior_portfolio if start_date
|
||||
end
|
||||
|
||||
def run
|
||||
holdings = []
|
||||
|
||||
sync_date_range.each do |date|
|
||||
holdings += build_holdings_for_date(date)
|
||||
end
|
||||
|
||||
upsert_holdings holdings
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :account, :sync_date_range
|
||||
|
||||
def sync_entries
|
||||
@sync_entries ||= account.entries
|
||||
.account_trades
|
||||
.includes(entryable: :security)
|
||||
.where("date >= ?", sync_date_range.begin)
|
||||
.order(:date)
|
||||
end
|
||||
|
||||
def get_cached_price(ticker, date)
|
||||
return nil unless security_prices.key?(ticker)
|
||||
|
||||
price = security_prices[ticker].find { |p| p.date == date }
|
||||
price ? price[:price] : nil
|
||||
end
|
||||
|
||||
def security_prices
|
||||
@security_prices ||= begin
|
||||
prices = {}
|
||||
ticker_securities = {}
|
||||
|
||||
sync_entries.each do |entry|
|
||||
security = entry.account_trade.security
|
||||
unless ticker_securities[security.ticker]
|
||||
ticker_securities[security.ticker] = {
|
||||
security: security,
|
||||
start_date: entry.date
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
ticker_securities.each do |ticker, data|
|
||||
fetched_prices = Security::Price.find_prices(
|
||||
security: data[:security],
|
||||
start_date: data[:start_date],
|
||||
end_date: Date.current
|
||||
)
|
||||
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: data[:start_date], end_date: Date.current, cache: false).run
|
||||
prices[ticker] = gapfilled_prices
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |ticker, holding|
|
||||
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
|
||||
trade_price = trade&.account_trade&.price
|
||||
|
||||
price = get_cached_price(ticker, date) || trade_price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
security_id: holding[:security_id],
|
||||
qty: holding[:qty],
|
||||
price: price,
|
||||
amount: price ? (price * holding[:qty]) : nil,
|
||||
currency: holding[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
def generate_next_portfolio(prior_portfolio, trade_entries)
|
||||
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.ticker] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
currency: entry.currency,
|
||||
security_id: trade.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_holdings(holdings)
|
||||
current_time = Time.now
|
||||
holdings_to_upsert = holdings.map do |holding|
|
||||
holding.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
|
||||
end
|
||||
|
||||
def load_prior_portfolio
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.ticker] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
currency: holding.currency,
|
||||
security_id: holding.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(start_date)
|
||||
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
end
|
||||
156
app/models/account/holding_calculator.rb
Normal file
156
app/models/account/holding_calculator.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
class Account::HoldingCalculator
|
||||
def initialize(account)
|
||||
@account = account
|
||||
@securities_cache = {}
|
||||
end
|
||||
|
||||
def calculate(reverse: false)
|
||||
preload_securities
|
||||
calculated_holdings = reverse ? reverse_holdings : forward_holdings
|
||||
gapfill_holdings(calculated_holdings)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :securities_cache
|
||||
|
||||
def reverse_holdings
|
||||
current_holding_quantities = load_current_holding_quantities
|
||||
prior_holding_quantities = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
Date.current.downto(portfolio_start_date).map do |date|
|
||||
today_trades = trades.select { |t| t.date == date }
|
||||
prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
|
||||
holdings += generate_holding_records(current_holding_quantities, date)
|
||||
current_holding_quantities = prior_holding_quantities
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
def forward_holdings
|
||||
prior_holding_quantities = load_empty_holding_quantities
|
||||
current_holding_quantities = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
portfolio_start_date.upto(Date.current).map do |date|
|
||||
today_trades = trades.select { |t| t.date == date }
|
||||
current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true)
|
||||
holdings += generate_holding_records(current_holding_quantities, date)
|
||||
prior_holding_quantities = current_holding_quantities
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
def generate_holding_records(portfolio, date)
|
||||
portfolio.map do |security_id, qty|
|
||||
security = securities_cache[security_id]
|
||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
||||
|
||||
next if price.blank?
|
||||
|
||||
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
|
||||
|
||||
account.holdings.build(
|
||||
security: security.dig(:security),
|
||||
date: date,
|
||||
qty: qty,
|
||||
price: converted_price,
|
||||
currency: account.currency,
|
||||
amount: qty * converted_price
|
||||
)
|
||||
end.compact
|
||||
end
|
||||
|
||||
def gapfill_holdings(holdings)
|
||||
filled_holdings = []
|
||||
|
||||
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
|
||||
next if security_holdings.empty?
|
||||
|
||||
sorted = security_holdings.sort_by(&:date)
|
||||
previous_holding = sorted.first
|
||||
|
||||
sorted.first.date.upto(Date.current) do |date|
|
||||
holding = security_holdings.find { |h| h.date == date }
|
||||
|
||||
if holding
|
||||
filled_holdings << holding
|
||||
previous_holding = holding
|
||||
else
|
||||
# Create a new holding based on the previous day's data
|
||||
filled_holdings << account.holdings.build(
|
||||
security: previous_holding.security,
|
||||
date: date,
|
||||
qty: previous_holding.qty,
|
||||
price: previous_holding.price,
|
||||
currency: previous_holding.currency,
|
||||
amount: previous_holding.amount
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filled_holdings
|
||||
end
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
end
|
||||
|
||||
def portfolio_start_date
|
||||
trades.first ? trades.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def preload_securities
|
||||
securities = trades.map(&:entryable).map(&:security).uniq
|
||||
|
||||
securities.each do |security|
|
||||
prices = Security::Price.find_prices(
|
||||
security: security,
|
||||
start_date: portfolio_start_date,
|
||||
end_date: Date.current
|
||||
)
|
||||
|
||||
@securities_cache[security.id] = {
|
||||
security: security,
|
||||
prices: prices
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
|
||||
new_quantities = holding_quantities.dup
|
||||
|
||||
today_trades.each do |trade|
|
||||
security_id = trade.entryable.security_id
|
||||
qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
|
||||
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||
end
|
||||
|
||||
new_quantities
|
||||
end
|
||||
|
||||
def load_empty_holding_quantities
|
||||
holding_quantities = {}
|
||||
|
||||
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
|
||||
holding_quantities[security_id] = 0
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
|
||||
def load_current_holding_quantities
|
||||
holding_quantities = load_empty_holding_quantities
|
||||
|
||||
account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
|
||||
holding_quantities[holding.security_id] = holding.qty
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
end
|
||||
@@ -1,82 +0,0 @@
|
||||
class Account::Sync < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
|
||||
|
||||
class << self
|
||||
def for(account, start_date: nil)
|
||||
create! account: account, start_date: start_date
|
||||
end
|
||||
|
||||
def latest
|
||||
order(created_at: :desc).first
|
||||
end
|
||||
end
|
||||
|
||||
def run
|
||||
start!
|
||||
|
||||
account.resolve_stale_issues
|
||||
|
||||
sync_balances
|
||||
sync_holdings
|
||||
|
||||
complete!
|
||||
rescue StandardError => error
|
||||
account.observe_unknown_issue(error)
|
||||
fail! error
|
||||
|
||||
raise error if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_balances
|
||||
Account::Balance::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
Account::Holding::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def start!
|
||||
update! status: "syncing", last_ran_at: Time.now
|
||||
broadcast_start
|
||||
end
|
||||
|
||||
def complete!
|
||||
update! status: "completed"
|
||||
|
||||
if account.has_issues?
|
||||
broadcast_result type: "alert", message: account.highest_priority_issue.title
|
||||
else
|
||||
broadcast_result type: "notice", message: "Sync complete"
|
||||
end
|
||||
end
|
||||
|
||||
def fail!(error)
|
||||
update! status: "failed", error: error.message
|
||||
broadcast_result type: "alert", message: I18n.t("account.sync.failed")
|
||||
end
|
||||
|
||||
def broadcast_start
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { id: id, type: "processing", message: "Syncing account balances" }
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_result(type:, message:)
|
||||
broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { type: type, message: message }
|
||||
)
|
||||
|
||||
account.family.broadcast_refresh
|
||||
end
|
||||
end
|
||||
@@ -1,29 +0,0 @@
|
||||
module Account::Syncable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def sync(start_date: nil)
|
||||
all.each { |a| a.sync_later(start_date: start_date) }
|
||||
end
|
||||
end
|
||||
|
||||
def syncing?
|
||||
syncs.syncing.any?
|
||||
end
|
||||
|
||||
def latest_sync_date
|
||||
syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date
|
||||
end
|
||||
|
||||
def needs_sync?
|
||||
latest_sync_date.nil? || latest_sync_date < Date.current
|
||||
end
|
||||
|
||||
def sync_later(start_date: nil)
|
||||
AccountSyncJob.perform_later(self, start_date: start_date)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
Account::Sync.for(self, start_date: start_date).run
|
||||
end
|
||||
end
|
||||
128
app/models/account/syncer.rb
Normal file
128
app/models/account/syncer.rb
Normal file
@@ -0,0 +1,128 @@
|
||||
class Account::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@start_date = start_date
|
||||
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
|
||||
attr_reader :account, :start_date
|
||||
|
||||
def account_start_date
|
||||
@account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day
|
||||
end
|
||||
|
||||
def update_account_info(balances, holdings)
|
||||
new_balance = balances.sort_by(&:date).last.balance
|
||||
new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount)
|
||||
new_cash_balance = new_balance - new_holdings_value
|
||||
|
||||
account.update!(
|
||||
balance: new_balance,
|
||||
cash_balance: new_cash_balance
|
||||
)
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
calculator = Account::HoldingCalculator.new(account)
|
||||
calculated_holdings = calculator.calculate(reverse: account.plaid_account_id.present?)
|
||||
|
||||
current_time = Time.now
|
||||
|
||||
Account.transaction do
|
||||
load_holdings(calculated_holdings)
|
||||
|
||||
# Purge outdated holdings
|
||||
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
|
||||
end
|
||||
|
||||
calculated_holdings
|
||||
end
|
||||
|
||||
def sync_balances(holdings)
|
||||
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
|
||||
calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date)
|
||||
|
||||
Account.transaction do
|
||||
load_balances(calculated_balances)
|
||||
|
||||
# Purge outdated balances
|
||||
account.balances.delete_by("date < ?", account_start_date)
|
||||
end
|
||||
|
||||
calculated_balances
|
||||
end
|
||||
|
||||
def convert_records_to_family_currency(balances, holdings)
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
exchange_rates = ExchangeRate.find_rates(
|
||||
from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: balances.first.date
|
||||
)
|
||||
|
||||
converted_balances = balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
|
||||
account.balances.build(
|
||||
date: balance.date,
|
||||
balance: exchange_rate.rate * balance.balance,
|
||||
currency: to_currency
|
||||
) if exchange_rate.present?
|
||||
end
|
||||
|
||||
converted_holdings = holdings.map do |holding|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == holding.date }
|
||||
|
||||
account.holdings.build(
|
||||
security: holding.security,
|
||||
date: holding.date,
|
||||
amount: exchange_rate.rate * holding.amount,
|
||||
currency: to_currency
|
||||
) if exchange_rate.present?
|
||||
end
|
||||
|
||||
Account.transaction do
|
||||
load_balances(converted_balances)
|
||||
load_holdings(converted_holdings)
|
||||
end
|
||||
end
|
||||
|
||||
def load_balances(balances = [])
|
||||
current_time = Time.now
|
||||
account.balances.upsert_all(
|
||||
balances.map { |b| b.attributes
|
||||
.slice("date", "balance", "cash_balance", "currency")
|
||||
.merge("updated_at" => current_time) },
|
||||
unique_by: %i[account_id date currency]
|
||||
)
|
||||
end
|
||||
|
||||
def load_holdings(holdings = [])
|
||||
current_time = Time.now
|
||||
account.holdings.upsert_all(
|
||||
holdings.map { |h| h.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("updated_at" => current_time) },
|
||||
unique_by: %i[account_id security_id date currency]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -5,35 +5,11 @@ class Account::Trade < ApplicationRecord
|
||||
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, presence: true, numericality: { other_than: 0 }
|
||||
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 "
|
||||
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||
entry.name || generated
|
||||
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?
|
||||
|
||||
|
||||
@@ -1,33 +1,113 @@
|
||||
class Account::TradeBuilder < Account::EntryBuilder
|
||||
class Account::TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[buy sell].freeze
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||
attr_reader :buildable
|
||||
|
||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
||||
validates :price, numericality: { greater_than: 0 }
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_entry
|
||||
end
|
||||
buildable.save
|
||||
end
|
||||
|
||||
def errors
|
||||
buildable.errors
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
buildable.sync_account_later
|
||||
end
|
||||
|
||||
private
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
when "deposit", "withdrawal"
|
||||
build_transfer
|
||||
when "interest"
|
||||
build_interest
|
||||
else
|
||||
raise "Unknown trade type: #{type}"
|
||||
end
|
||||
end
|
||||
|
||||
def create_entry
|
||||
account.entries.account_trades.create! \
|
||||
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: amount,
|
||||
currency: account.currency,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Trade.new(
|
||||
security: security,
|
||||
qty: signed_qty,
|
||||
price: price.to_d,
|
||||
currency: account.currency
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
|
||||
|
||||
if transfer_account
|
||||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
else
|
||||
account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_interest
|
||||
account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
return nil unless type.in?([ "buy", "sell" ])
|
||||
|
||||
type == "sell" ? -qty.to_d : qty.to_d
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "buy", "sell"
|
||||
signed_qty * price.to_d
|
||||
when "deposit", "withdrawal"
|
||||
type == "deposit" ? -amount.to_d : amount.to_d
|
||||
when "interest"
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
|
||||
def family
|
||||
account.family
|
||||
end
|
||||
|
||||
def security
|
||||
@@ -40,14 +120,4 @@ class Account::TradeBuilder < Account::EntryBuilder
|
||||
|
||||
security
|
||||
end
|
||||
|
||||
def amount
|
||||
price.to_d * signed_qty
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
_qty = qty.to_d
|
||||
_qty = _qty * -1 if type == "sell"
|
||||
_qty
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
class Account::TransactionBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[income expense interest transfer_in transfer_out].freeze
|
||||
|
||||
attr_accessor :type, :amount, :date, :account, :transfer_account_id
|
||||
|
||||
validates :type, :amount, :date, presence: true
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
transfer? ? create_transfer : create_transaction
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transfer?
|
||||
%w[transfer_in transfer_out].include?(type)
|
||||
end
|
||||
|
||||
def create_transfer
|
||||
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
|
||||
|
||||
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
|
||||
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
|
||||
|
||||
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
|
||||
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
|
||||
|
||||
Account::Transfer.create! entries: [ outflow, inflow ]
|
||||
|
||||
inflow
|
||||
end
|
||||
|
||||
def create_unlinked_transfer(account_id, amount)
|
||||
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
|
||||
end
|
||||
|
||||
def create_transaction
|
||||
build_entry(account.id, signed_amount).tap(&:save!)
|
||||
end
|
||||
|
||||
def build_entry(account_id, amount, marked_as_transfer: false)
|
||||
Account::Entry.new \
|
||||
account_id: account_id,
|
||||
amount: amount,
|
||||
currency: account.currency,
|
||||
date: date,
|
||||
marked_as_transfer: marked_as_transfer,
|
||||
entryable: Account::Transaction.new
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "expense", "transfer_out"
|
||||
amount.to_d
|
||||
else
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
end
|
||||
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,101 +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
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:)
|
||||
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
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: amount.abs * -1,
|
||||
currency: from_account.currency,
|
||||
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,53 +1,3 @@
|
||||
class Account::Valuation < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
oldest? ? "Initial balance" : entry.name || "Balance update"
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
def icon
|
||||
oldest? ? "plus" : entry.trend.icon
|
||||
end
|
||||
|
||||
def color
|
||||
oldest? ? "#D444F1" : entry.trend.color
|
||||
end
|
||||
|
||||
private
|
||||
def oldest?
|
||||
@oldest ||= account.entries.where("date < ?", entry.date).empty?
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= entry.account
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new(
|
||||
current: entry.amount_money,
|
||||
previous: prior_balance&.balance_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
def prior_balance
|
||||
@prior_balance ||= account.balances
|
||||
.where("date < ?", entry.date)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
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
|
||||
@@ -18,19 +18,12 @@ module Accountable
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
def value
|
||||
account.balance_money
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: account.asset? ? "up" : "down")
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
def post_sync
|
||||
broadcast_replace_to(
|
||||
account,
|
||||
target: "chart_account_#{account.id}",
|
||||
partial: "accounts/show/chart",
|
||||
locals: { account: account }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
14
app/models/concerns/plaidable.rb
Normal file
14
app/models/concerns/plaidable.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Plaidable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def plaid_provider
|
||||
Provider::Plaid.new if Rails.application.config.plaid
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def plaid_provider
|
||||
self.class.plaid_provider
|
||||
end
|
||||
end
|
||||
37
app/models/concerns/syncable.rb
Normal file
37
app/models/concerns/syncable.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
module Syncable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :syncs, as: :syncable, dependent: :destroy
|
||||
end
|
||||
|
||||
def syncing?
|
||||
syncs.where(status: [ :syncing, :pending ]).any?
|
||||
end
|
||||
|
||||
def sync_later(start_date: nil)
|
||||
new_sync = syncs.create!(start_date: start_date)
|
||||
SyncJob.perform_later(new_sync)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
syncs.create!(start_date: start_date).perform
|
||||
end
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
raise NotImplementedError, "Subclasses must implement the `sync_data` method"
|
||||
end
|
||||
|
||||
def post_sync
|
||||
# no-op, syncable can optionally provide implementation
|
||||
end
|
||||
|
||||
def sync_error
|
||||
latest_sync.error
|
||||
end
|
||||
|
||||
private
|
||||
def latest_sync
|
||||
syncs.order(created_at: :desc).first
|
||||
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!
|
||||
@@ -107,8 +110,7 @@ class Demo::Generator
|
||||
accountable: CreditCard.new,
|
||||
name: "Chase Credit Card",
|
||||
balance: 2300,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
currency: "USD"
|
||||
|
||||
50.times do
|
||||
merchant = random_family_record(Merchant)
|
||||
@@ -134,8 +136,7 @@ class Demo::Generator
|
||||
accountable: Depository.new,
|
||||
name: "Chase Checking",
|
||||
balance: 15000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
currency: "USD"
|
||||
|
||||
10.times do
|
||||
create_transaction! \
|
||||
@@ -159,8 +160,7 @@ class Demo::Generator
|
||||
name: "Demo Savings",
|
||||
balance: 40000,
|
||||
currency: "USD",
|
||||
subtype: "savings",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
subtype: "savings"
|
||||
|
||||
income_category = categories.find { |c| c.name == "Income" }
|
||||
income_tag = tags.find { |t| t.name == "Emergency Fund" }
|
||||
@@ -175,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"
|
||||
@@ -208,8 +242,7 @@ class Demo::Generator
|
||||
accountable: Investment.new,
|
||||
name: "Robinhood",
|
||||
balance: 100000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||
currency: "USD"
|
||||
|
||||
aapl = Security.find_by(ticker: "AAPL")
|
||||
tm = Security.find_by(ticker: "TM")
|
||||
@@ -307,6 +340,7 @@ class Demo::Generator
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
name: "Balance update",
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
@@ -322,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,5 +1,7 @@
|
||||
class Family < ApplicationRecord
|
||||
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" ]
|
||||
include Plaidable, Syncable
|
||||
|
||||
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
|
||||
|
||||
@@ -7,17 +9,71 @@ class Family < ApplicationRecord
|
||||
has_many :invitations, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :entries, through: :accounts
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
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 }
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
accounts.manual.each do |account|
|
||||
account.sync_data(start_date: start_date)
|
||||
end
|
||||
|
||||
plaid_data = []
|
||||
|
||||
plaid_items.each do |plaid_item|
|
||||
plaid_data << plaid_item.sync_data(start_date: start_date)
|
||||
end
|
||||
|
||||
plaid_data
|
||||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_refresh
|
||||
end
|
||||
|
||||
def syncing?
|
||||
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
|
||||
end
|
||||
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil)
|
||||
return nil unless plaid_provider
|
||||
|
||||
plaid_provider.get_link_token(
|
||||
user_id: id,
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: redirect_url,
|
||||
accountable_type: accountable_type
|
||||
).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)
|
||||
@@ -44,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",
|
||||
@@ -52,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
|
||||
@@ -72,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 = []
|
||||
@@ -93,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
|
||||
|
||||
@@ -116,24 +172,18 @@ class Family < ApplicationRecord
|
||||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync_later(start_date: start_date || account.last_sync_date)
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def needs_sync?
|
||||
last_synced_at.nil? || last_synced_at.to_date < Date.current
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_provider&.usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
self.class.synth_provider&.usage&.utilization.to_i >= 100
|
||||
end
|
||||
|
||||
def synth_valid?
|
||||
self.class.synth_provider&.healthy?
|
||||
end
|
||||
|
||||
def subscribed?
|
||||
stripe_subscription_status == "active"
|
||||
end
|
||||
@@ -141,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
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
class Gapfiller
|
||||
attr_reader :series
|
||||
|
||||
def initialize(series, start_date:, end_date:, cache:)
|
||||
@series = series
|
||||
@date_range = start_date..end_date
|
||||
@cache = cache
|
||||
end
|
||||
|
||||
def run
|
||||
gapfilled_records = []
|
||||
|
||||
date_range.each do |date|
|
||||
record = series.find { |r| r.date == date }
|
||||
|
||||
if should_gapfill?(date, record)
|
||||
prev_record = gapfilled_records.find { |r| r.date == date - 1.day }
|
||||
|
||||
if prev_record
|
||||
new_record = create_gapfilled_record(prev_record, date)
|
||||
gapfilled_records << new_record
|
||||
end
|
||||
else
|
||||
gapfilled_records << record if record
|
||||
end
|
||||
end
|
||||
|
||||
gapfilled_records
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :date_range, :cache
|
||||
|
||||
def should_gapfill?(date, record)
|
||||
(date.on_weekend? || holiday?(date)) && record.nil?
|
||||
end
|
||||
|
||||
def holiday?(date)
|
||||
Holidays.on(date, :federalreserve, :us, :observed, :informal).any?
|
||||
end
|
||||
|
||||
def create_gapfilled_record(prev_record, date)
|
||||
new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at"))
|
||||
new_record.date = date
|
||||
new_record.save! if cache
|
||||
new_record
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ class Import::AccountMapping < Import::Mapping
|
||||
end
|
||||
|
||||
def selectable_values
|
||||
family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] }
|
||||
family_accounts = import.family.accounts.manual.alphabetically.map { |account| [ account.name, account.id ] }
|
||||
|
||||
unless key.blank?
|
||||
family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ]
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
class Institution < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :accounts, dependent: :nullify
|
||||
has_one_attached :logo
|
||||
|
||||
scope :alphabetically, -> { order(name: :asc) }
|
||||
|
||||
def sync
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def syncing?
|
||||
accounts.active.any? { |account| account.syncing? }
|
||||
end
|
||||
|
||||
def has_issues?
|
||||
accounts.active.any? { |account| account.has_issues? }
|
||||
end
|
||||
end
|
||||
@@ -16,37 +16,6 @@ class Investment < ApplicationRecord
|
||||
[ "Angel", "angel" ]
|
||||
].freeze
|
||||
|
||||
def value
|
||||
account.balance_money + holdings_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
|
||||
|
||||
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
combined_series = balance_series.map do |balance|
|
||||
holding_amount = holdings_by_date[balance.date] || 0
|
||||
|
||||
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
|
||||
end
|
||||
|
||||
if combined_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.new(combined_series)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def color
|
||||
"#1570EF"
|
||||
end
|
||||
|
||||
159
app/models/plaid_account.rb
Normal file
159
app/models/plaid_account.rb
Normal file
@@ -0,0 +1,159 @@
|
||||
class PlaidAccount < ApplicationRecord
|
||||
include Plaidable
|
||||
|
||||
TYPE_MAPPING = {
|
||||
"depository" => Depository,
|
||||
"credit" => CreditCard,
|
||||
"loan" => Loan,
|
||||
"investment" => Investment,
|
||||
"other" => OtherAsset
|
||||
}
|
||||
|
||||
belongs_to :plaid_item
|
||||
|
||||
has_one :account, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
class << self
|
||||
def find_or_create_from_plaid_data!(plaid_data, family)
|
||||
find_or_create_by!(plaid_id: plaid_data.account_id) do |a|
|
||||
a.account = family.accounts.new(
|
||||
name: plaid_data.name,
|
||||
balance: plaid_data.balances.current,
|
||||
currency: plaid_data.balances.iso_currency_code,
|
||||
accountable: TYPE_MAPPING[plaid_data.type].new
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_data!(plaid_account_data)
|
||||
update!(
|
||||
current_balance: plaid_account_data.balances.current,
|
||||
available_balance: plaid_account_data.balances.available,
|
||||
currency: plaid_account_data.balances.iso_currency_code,
|
||||
plaid_type: plaid_account_data.type,
|
||||
plaid_subtype: plaid_account_data.subtype,
|
||||
account_attributes: {
|
||||
id: account.id,
|
||||
# Plaid guarantees at least 1 of these
|
||||
balance: plaid_account_data.balances.current || plaid_account_data.balances.available,
|
||||
cash_balance: derive_plaid_cash_balance(plaid_account_data.balances)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_investments!(transactions:, holdings:, securities:)
|
||||
PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:)
|
||||
end
|
||||
|
||||
def sync_credit_data!(plaid_credit_data)
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
minimum_payment: plaid_credit_data.minimum_payment_amount,
|
||||
apr: plaid_credit_data.aprs.first&.apr_percentage
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_mortgage_data!(plaid_mortgage_data)
|
||||
create_initial_loan_balance(plaid_mortgage_data)
|
||||
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
rate_type: plaid_mortgage_data.interest_rate&.type,
|
||||
interest_rate: plaid_mortgage_data.interest_rate&.percentage
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_student_loan_data!(plaid_student_loan_data)
|
||||
create_initial_loan_balance(plaid_student_loan_data)
|
||||
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
rate_type: "fixed",
|
||||
interest_rate: plaid_student_loan_data.interest_rate_percentage
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_transactions!(added:, modified:, removed:)
|
||||
added.each do |plaid_txn|
|
||||
account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t|
|
||||
t.name = plaid_txn.name
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.entryable = Account::Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn.merchant_name)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
modified.each do |plaid_txn|
|
||||
existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id)
|
||||
|
||||
existing_txn.update!(
|
||||
amount: plaid_txn.amount,
|
||||
date: plaid_txn.date
|
||||
)
|
||||
end
|
||||
|
||||
removed.each do |plaid_txn|
|
||||
account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
plaid_item.family
|
||||
end
|
||||
|
||||
def transfer?(plaid_txn)
|
||||
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
|
||||
|
||||
transfer_categories.include?(plaid_txn.personal_finance_category.primary)
|
||||
end
|
||||
|
||||
def create_initial_loan_balance(loan_data)
|
||||
if loan_data.origination_principal_amount.present? && loan_data.origination_date.present?
|
||||
account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e|
|
||||
e.name = "Initial Principal"
|
||||
e.amount = loan_data.origination_principal_amount
|
||||
e.currency = account.currency
|
||||
e.date = loan_data.origination_date
|
||||
e.entryable = Account::Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||
def get_category(plaid_category)
|
||||
ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ]
|
||||
|
||||
return nil if ignored_categories.include?(plaid_category)
|
||||
|
||||
family.categories.find_or_create_by!(name: plaid_category.titleize)
|
||||
end
|
||||
|
||||
def get_merchant(plaid_merchant_name)
|
||||
return nil if plaid_merchant_name.blank?
|
||||
|
||||
family.merchants.find_or_create_by!(name: plaid_merchant_name)
|
||||
end
|
||||
|
||||
def derive_plaid_cash_balance(plaid_balances)
|
||||
if account.investment?
|
||||
plaid_balances.available || 0
|
||||
else
|
||||
# For now, we will not distinguish between "cash" and "overall" balance for non-investment accounts
|
||||
plaid_balances.current || plaid_balances.available
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user