Compare commits
109 Commits
v0.2.0-alp
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
56ab092f6b | ||
|
|
b3ef995d1f | ||
|
|
a113d573d6 | ||
|
|
3b928775a8 | ||
|
|
31d9d926f7 | ||
|
|
154a1a971b | ||
|
|
e434ed0e1f | ||
|
|
2722254be9 | ||
|
|
455257bf51 | ||
|
|
f2739b79fb | ||
|
|
cee9692b35 | ||
|
|
18266c3352 | ||
|
|
c3400856c7 | ||
|
|
a0ad33e47c | ||
|
|
65d46397d7 | ||
|
|
905eb7bbe8 | ||
|
|
65db49273c | ||
|
|
12e4f1067d | ||
|
|
85779b4038 | ||
|
|
793bd852a0 | ||
|
|
09b269273a | ||
|
|
47288a1629 | ||
|
|
2b61821336 | ||
|
|
7946cd7819 | ||
|
|
e7f09e6f71 | ||
|
|
5e2b932648 | ||
|
|
5533b84895 | ||
|
|
c9917674aa | ||
|
|
cd91e66618 | ||
|
|
490f44589e | ||
|
|
bf695972e4 | ||
|
|
7d8028b505 | ||
|
|
c2561b5fb4 | ||
|
|
e5eb69bdc7 | ||
|
|
3cd364af09 | ||
|
|
277fb3dc39 | ||
|
|
439e50bb3e | ||
|
|
2141cbb041 | ||
|
|
d78f582af2 | ||
|
|
2adb54da99 | ||
|
|
45935db5f3 |
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
|
||||
18
.env.example
18
.env.example
@@ -11,14 +11,10 @@
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# Exchange Rate & US Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches US stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
# Exchange Rate & Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# Non-US Stock Pricing API
|
||||
# This is used to fetch non-US stock prices. We use Marketstack.com for this and while they offer a free tier, it is quite limited. You'll almost certainly need their Basic plan, which is $9.99 per month.
|
||||
MARKETSTACK_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
@@ -114,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=
|
||||
@@ -3,6 +3,3 @@ SELF_HOSTED=false
|
||||
|
||||
# Enable Synth market data (careful, this will use your API credits)
|
||||
SYNTH_API_KEY=yourapikeyhere
|
||||
|
||||
# Enable Marketstack market data (careful, this will use your API credits)
|
||||
MARKETSTACK_API_KEY=yourapikeyhere
|
||||
|
||||
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*
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.3.4
|
||||
3.3.5
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Rails app lives here
|
||||
|
||||
6
Gemfile
6
Gemfile
@@ -3,7 +3,7 @@ source "https://rubygems.org"
|
||||
ruby file: ".ruby-version"
|
||||
|
||||
# Rails
|
||||
gem "rails", "~> 7.2.1"
|
||||
gem "rails", "~> 7.2.2"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
@@ -21,6 +21,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
# Hotwire
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
@@ -36,6 +37,7 @@ gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "jwt"
|
||||
gem "faraday"
|
||||
gem "faraday-retry"
|
||||
gem "faraday-multipart"
|
||||
@@ -48,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]
|
||||
|
||||
229
Gemfile.lock
229
Gemfile.lock
@@ -8,29 +8,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
actioncable (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
activejob (= 7.2.1.1)
|
||||
activerecord (= 7.2.1.1)
|
||||
activestorage (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
actionmailbox (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
actionview (= 7.2.1.1)
|
||||
activejob (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
actionmailer (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.1.1)
|
||||
actionview (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
@@ -39,36 +39,37 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
activerecord (= 7.2.1.1)
|
||||
activestorage (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
actiontext (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
activejob (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
activerecord (7.2.1.1)
|
||||
activemodel (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
activemodel (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
activejob (= 7.2.1.1)
|
||||
activerecord (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
activestorage (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.1.1)
|
||||
activesupport (7.2.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
@@ -82,23 +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.1018.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
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.176.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.4.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
@@ -131,7 +133,7 @@ GEM
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.3.4)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
@@ -153,14 +155,14 @@ GEM
|
||||
tzinfo
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
faraday (2.12.1)
|
||||
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-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)
|
||||
@@ -174,7 +176,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.4.2)
|
||||
good_job (4.5.1)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -183,11 +185,14 @@ 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)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
hotwire_combobox (0.3.2)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
@@ -212,12 +217,14 @@ GEM
|
||||
nokogiri (>= 1.6)
|
||||
intercom-rails (1.0.1)
|
||||
activesupport (> 4.0)
|
||||
io-console (0.7.2)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.1)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
json (2.8.2)
|
||||
jwt (2.9.3)
|
||||
base64
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.1)
|
||||
addressable (~> 2.8)
|
||||
@@ -227,8 +234,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.22.0)
|
||||
logger (1.6.2)
|
||||
loofah (2.23.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -240,12 +247,12 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.1)
|
||||
mocha (2.4.5)
|
||||
minitest (5.25.4)
|
||||
mocha (2.7.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.4.1)
|
||||
net-http (0.5.0)
|
||||
uri
|
||||
net-imap (0.5.0)
|
||||
date
|
||||
@@ -256,38 +263,42 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.17.0-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
nokogiri (1.17.0-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
nokogiri (1.17.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
nokogiri (1.17.0-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
nokogiri (1.17.0-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
nokogiri (1.17.0-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.1.0)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.8)
|
||||
pg (1.5.9)
|
||||
plaid (34.0.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
prism (1.2.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.1.2)
|
||||
psych (5.2.1)
|
||||
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)
|
||||
@@ -296,39 +307,38 @@ GEM
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.1.0)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
webrick (~> 1.8)
|
||||
rails (7.2.1.1)
|
||||
actioncable (= 7.2.1.1)
|
||||
actionmailbox (= 7.2.1.1)
|
||||
actionmailer (= 7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
actiontext (= 7.2.1.1)
|
||||
actionview (= 7.2.1.1)
|
||||
activejob (= 7.2.1.1)
|
||||
activemodel (= 7.2.1.1)
|
||||
activerecord (= 7.2.1.1)
|
||||
activestorage (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
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)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.1.1)
|
||||
railties (= 7.2.2)
|
||||
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.1)
|
||||
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)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (7.2.1.1)
|
||||
actionpack (= 7.2.1.1)
|
||||
activesupport (= 7.2.1.1)
|
||||
railties (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -341,13 +351,13 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
rbs (3.6.1)
|
||||
logger
|
||||
rdoc (6.7.0)
|
||||
rdoc (6.8.1)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.10)
|
||||
reline (0.5.12)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.8)
|
||||
rexml (3.3.9)
|
||||
rubocop (1.67.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
@@ -376,13 +386,13 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.20.1)
|
||||
ruby-lsp (0.22.1)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.20)
|
||||
ruby-lsp (>= 0.20.0, < 0.21.0)
|
||||
ruby-lsp-rails (0.3.27)
|
||||
ruby-lsp (>= 0.22.0, < 0.23.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
@@ -392,17 +402,17 @@ GEM
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.25.0)
|
||||
securerandom (0.4.0)
|
||||
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.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.21.0)
|
||||
sentry-ruby (5.21.0)
|
||||
sentry-ruby (~> 5.22.0)
|
||||
sentry-ruby (5.22.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -412,12 +422,12 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11609)
|
||||
sorbet-runtime (0.5.11663)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
stripe (13.0.1)
|
||||
stringio (3.1.2)
|
||||
stripe (13.2.0)
|
||||
tailwindcss-rails (3.0.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby
|
||||
@@ -430,15 +440,15 @@ GEM
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.1)
|
||||
timeout (0.4.2)
|
||||
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)
|
||||
@@ -450,7 +460,6 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.2)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -483,22 +492,24 @@ DEPENDENCIES
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
good_job
|
||||
holidays
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
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.1)
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
@@ -518,7 +529,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.4p94
|
||||
ruby 3.3.5p100
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.9
|
||||
2.5.22
|
||||
|
||||
@@ -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)._
|
||||
@@ -42,7 +42,7 @@ The instructions below are for developers to get started with contributing to th
|
||||
|
||||
### Requirements
|
||||
|
||||
- Ruby 3.3.4
|
||||
- See `.ruby-version` file for required Ruby version
|
||||
- PostgreSQL >9.3 (ideally, latest stable version)
|
||||
|
||||
After cloning the repo, the basic setup commands are:
|
||||
|
||||
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 |
@@ -19,7 +19,8 @@
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
|
||||
.form-field__label, .hw-combobox__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@@ -112,7 +113,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 {
|
||||
@@ -120,6 +121,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper, .hw-combobox__input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.hw-combobox__main__wrapper {
|
||||
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||
}
|
||||
|
||||
.hw-combobox__listbox {
|
||||
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||
}
|
||||
|
||||
.hw_combobox__pagination__wrapper {
|
||||
@apply h-px;
|
||||
|
||||
&:only-child {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||
--hw-handle-width: 20px;
|
||||
--hw-handle-height: 20px;
|
||||
--hw-handle-offset-right: 0px;
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
@layer utilities {
|
||||
.scrollbar::-webkit-scrollbar {
|
||||
@@ -134,4 +162,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,47 +2,25 @@ class Account::EntriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
render entryable_view_path(:show)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
redirect_to account_url(@entry.account), notice: t(".success")
|
||||
def index
|
||||
@q = search_params
|
||||
@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])
|
||||
def entries_scope
|
||||
scope = Current.family.entries
|
||||
scope = scope.where(account: @account) if @account
|
||||
scope
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:search)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,59 +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_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_back_or_to account_path(@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
|
||||
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,57 +1,55 @@
|
||||
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
|
||||
@entry.update!(entry_params)
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
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
|
||||
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 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_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature,
|
||||
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
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
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],
|
||||
name: transfer_params[:name]
|
||||
amount: transfer_params[:amount].to_d
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
@@ -28,18 +29,33 @@ class Account::TransfersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update_entries!(transfer_update_params)
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy_and_remove_marks!
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
@transfer = Account::Transfer.find(params[:id])
|
||||
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)
|
||||
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_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
include EntryableResource
|
||||
end
|
||||
|
||||
@@ -1,84 +1,51 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[edit show destroy sync update]
|
||||
before_action :set_account, only: %i[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
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@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
|
||||
|
||||
def list
|
||||
@period = Period.from_param(params[:period])
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Account.new(currency: Current.family.currency)
|
||||
@account.accountable = Accountable.from_type(params[:type])&.new if params[:type].present?
|
||||
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def edit
|
||||
@account.accountable.build_address if @account.accountable.is_a?(Property) && @account.accountable.address.blank?
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
@account.sync_later
|
||||
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
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
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :mode, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
75
app/controllers/concerns/accountable_resource.rb
Normal file
75
app/controllers/concerns/accountable_resource.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
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
|
||||
def permitted_accountable_attributes(*attrs)
|
||||
@permitted_accountable_attributes = attrs if attrs.any?
|
||||
@permitted_accountable_attributes ||= [ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: accountable_type.new
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
|
||||
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("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@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
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(
|
||||
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
accountable_attributes: self.class.permitted_accountable_attributes
|
||||
)
|
||||
end
|
||||
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
|
||||
|
||||
126
app/controllers/concerns/entryable_resource.rb
Normal file
126
app/controllers/concerns/entryable_resource.rb
Normal file
@@ -0,0 +1,126 @@
|
||||
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 }
|
||||
)
|
||||
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, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,23 +0,0 @@
|
||||
module Filterable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_period
|
||||
@period = Period.find_by_name(params[:period])
|
||||
if @period.nil?
|
||||
start_date = params[:start_date].presence&.to_date
|
||||
end_date = params[:end_date].presence&.to_date
|
||||
if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date
|
||||
@period = Period.new(name: "custom", date_range: start_date..end_date)
|
||||
else
|
||||
params[:period] = "last_30_days"
|
||||
@period = Period.find_by_name(params[:period])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ module Invitable
|
||||
|
||||
private
|
||||
def invite_code_required?
|
||||
return false if @invitation.present?
|
||||
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
|
||||
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?
|
||||
|
||||
@@ -1,41 +1,12 @@
|
||||
class CreditCardsController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :mode, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:available_credit,
|
||||
:minimum_payment,
|
||||
:apr,
|
||||
:annual_fee,
|
||||
:expiration_date
|
||||
]
|
||||
)
|
||||
end
|
||||
permitted_accountable_attributes(
|
||||
:id,
|
||||
:available_credit,
|
||||
:minimum_payment,
|
||||
:apr,
|
||||
:annual_fee,
|
||||
:expiration_date
|
||||
)
|
||||
end
|
||||
|
||||
3
app/controllers/cryptos_controller.rb
Normal file
3
app/controllers/cryptos_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class CryptosController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
3
app/controllers/depositories_controller.rb
Normal file
3
app/controllers/depositories_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class DepositoriesController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
@@ -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
|
||||
3
app/controllers/investments_controller.rb
Normal file
3
app/controllers/investments_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class InvestmentsController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
42
app/controllers/invitations_controller.rb
Normal file
42
app/controllers/invitations_controller.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class InvitationsController < ApplicationController
|
||||
skip_authentication only: :accept
|
||||
def new
|
||||
@invitation = Invitation.new
|
||||
end
|
||||
|
||||
def create
|
||||
unless Current.user.admin?
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
@invitation = Current.family.invitations.build(invitation_params)
|
||||
@invitation.inviter = Current.user
|
||||
|
||||
if @invitation.save
|
||||
InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted?
|
||||
flash[:notice] = t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
end
|
||||
|
||||
def accept
|
||||
@invitation = Invitation.find_by!(token: params[:id])
|
||||
|
||||
if @invitation.pending?
|
||||
redirect_to new_registration_path(invitation: @invitation.token)
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
params.require(:invitation).permit(:email, :role)
|
||||
end
|
||||
end
|
||||
@@ -3,8 +3,9 @@ class Issue::ExchangeRateProviderMissingsController < ApplicationController
|
||||
|
||||
def update
|
||||
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
|
||||
@issue.issuable.sync_later
|
||||
redirect_back_or_to account_path(@issue.issuable)
|
||||
account = @issue.issuable
|
||||
account.sync_later
|
||||
redirect_back_or_to account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,39 +1,7 @@
|
||||
class LoansController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:rate_type,
|
||||
:interest_rate,
|
||||
:term_months
|
||||
]
|
||||
)
|
||||
end
|
||||
permitted_accountable_attributes(
|
||||
:id, :rate_type, :interest_rate, :term_months
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class OnboardingsController < ApplicationController
|
||||
layout "application"
|
||||
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
def show
|
||||
end
|
||||
@@ -13,7 +13,12 @@ class OnboardingsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def load_invitation
|
||||
@invitation = Current.family.invitations.accepted.find_by(email: Current.user.email)
|
||||
end
|
||||
end
|
||||
|
||||
3
app/controllers/other_assets_controller.rb
Normal file
3
app/controllers/other_assets_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherAssetsController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
3
app/controllers/other_liabilities_controller.rb
Normal file
3
app/controllers/other_liabilities_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherLiabilitiesController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
@@ -2,9 +2,8 @@ class PagesController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[early_access]
|
||||
layout :with_sidebar, except: %i[early_access]
|
||||
|
||||
include Filterable
|
||||
|
||||
def dashboard
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@@ -20,7 +19,7 @@ class PagesController < ApplicationController
|
||||
@top_earners = snapshot_account_transactions[:top_earners]
|
||||
@top_savers = snapshot_account_transactions[:top_savers]
|
||||
|
||||
@accounts = Current.family.accounts
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class PasswordResetsController < ApplicationController
|
||||
).password_reset.deliver_later
|
||||
end
|
||||
|
||||
redirect_to root_path, notice: t(".requested")
|
||||
redirect_to new_password_reset_path(step: "pending")
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
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
|
||||
@@ -1,40 +1,21 @@
|
||||
class PropertiesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
permitted_accountable_attributes(
|
||||
:id, :year_built, :area_unit, :area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
)
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
def edit
|
||||
@account.accountable.address ||= Address.new
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:year_built,
|
||||
:area_unit,
|
||||
:area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController
|
||||
layout "auth"
|
||||
|
||||
before_action :set_user, only: :create
|
||||
before_action :set_invitation
|
||||
before_action :claim_invite_code, only: :create, if: :invite_code_required?
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
@user = User.new(email: @invitation&.email)
|
||||
end
|
||||
|
||||
def create
|
||||
family = Family.new
|
||||
@user.family = family
|
||||
@user.role = :admin
|
||||
if @invitation
|
||||
@user.family = @invitation.family
|
||||
@user.role = @invitation.role
|
||||
@user.email = @invitation.email
|
||||
else
|
||||
family = Family.new
|
||||
@user.family = family
|
||||
@user.role = :admin
|
||||
end
|
||||
|
||||
if @user.save
|
||||
Category.create_default_categories(@user.family)
|
||||
@invitation&.update!(accepted_at: Time.current)
|
||||
Category.create_default_categories(@user.family) unless @invitation
|
||||
@session = create_session_for(@user)
|
||||
flash[:notice] = t(".success")
|
||||
redirect_to root_path
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
render :new, status: :unprocessable_entity
|
||||
render :new, status: :unprocessable_entity, alert: t(".failure")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
def set_invitation
|
||||
token = params[:invitation]
|
||||
token ||= params[:user][:invitation] if params[:user].present?
|
||||
@invitation = Invitation.pending.find_by(token: token)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code, :invitation)
|
||||
end
|
||||
|
||||
def user_params(specific_param = nil)
|
||||
params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)
|
||||
specific_param ? params[specific_param] : params
|
||||
end
|
||||
|
||||
def claim_invite_code
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class Settings::ProfilesController < SettingsController
|
||||
def show
|
||||
@user = Current.user
|
||||
@users = Current.family.users.order(:created_at)
|
||||
@pending_invitations = Current.family.invitations.pending
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,93 +13,13 @@ class TransactionsController < ApplicationController
|
||||
}
|
||||
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 account_path(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -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 ]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,41 +1,7 @@
|
||||
class VehiclesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:make,
|
||||
:model,
|
||||
:year,
|
||||
:mileage_value,
|
||||
:mileage_unit
|
||||
]
|
||||
)
|
||||
end
|
||||
permitted_accountable_attributes(
|
||||
:id, :make, :model, :year, :mileage_value, :mileage_unit
|
||||
)
|
||||
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"]
|
||||
|
||||
@@ -12,43 +12,13 @@ module Account::EntriesHelper
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
|
||||
def entry_icon(entry, is_oldest: false)
|
||||
if is_oldest
|
||||
"keyboard"
|
||||
elsif entry.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif entry.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def entry_style(entry, is_oldest: false)
|
||||
color = is_oldest ? "#D444F1" : entry.trend.color
|
||||
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
def entry_name(entry)
|
||||
if entry.account_trade?
|
||||
trade = entry.account_trade
|
||||
prefix = trade.sell? ? "Sell " : "Buy "
|
||||
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
|
||||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name || "Transaction"
|
||||
end
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
def entries_by_date(entries, selectable: true, totals: false)
|
||||
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
end
|
||||
|
||||
|
||||
@@ -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,12 +1,25 @@
|
||||
module AccountsHelper
|
||||
def permitted_accountable_partial(account, name = nil)
|
||||
permitted_names = %w[tooltip header tabs form]
|
||||
folder = account.accountable_type.underscore
|
||||
name ||= account.accountable_type.underscore
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
|
||||
raise "Unpermitted accountable partial: #{name}" unless permitted_names.include?(name)
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
|
||||
|
||||
"accounts/accountables/#{folder}/#{name}"
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
when 7
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
else
|
||||
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_card(title:, &block)
|
||||
@@ -38,64 +51,8 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
# Eventually, we'll have an accountable form for each type of accountable, so
|
||||
# this helper is a convenience for now to reuse common logic in the accounts controller
|
||||
def new_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
properties_path
|
||||
when "Vehicle"
|
||||
vehicles_path
|
||||
when "Loan"
|
||||
loans_path
|
||||
when "CreditCard"
|
||||
credit_cards_path
|
||||
else
|
||||
accounts_path
|
||||
end
|
||||
end
|
||||
|
||||
def edit_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
property_path(account)
|
||||
when "Vehicle"
|
||||
vehicle_path(account)
|
||||
when "Loan"
|
||||
loan_path(account)
|
||||
when "CreditCard"
|
||||
credit_card_path(account)
|
||||
else
|
||||
account_path(account)
|
||||
end
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
|
||||
|
||||
return [ value_tab ] if account.other_asset? || account.other_liability?
|
||||
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
|
||||
return [ holdings_tab, cash_tab, trades_tab, value_tab ] if account.investment?
|
||||
return [ overview_tab, value_tab, transactions_tab ] if account.loan? || account.credit_card?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
end
|
||||
|
||||
def selected_account_tab(account)
|
||||
available_tabs = account_tabs(account)
|
||||
|
||||
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
|
||||
|
||||
tab || available_tabs.first
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ module ApplicationHelper
|
||||
def date_format_options
|
||||
[
|
||||
[ "DD-MM-YYYY", "%d-%m-%Y" ],
|
||||
[ "DD.MM.YY", "%d.%m.%Y" ],
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
[ "YYYY-MM-DD", "%Y-%m-%d" ],
|
||||
[ "DD/MM/YYYY", "%d/%m/%Y" ],
|
||||
@@ -61,9 +62,9 @@ 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)
|
||||
@@ -122,29 +123,6 @@ module ApplicationHelper
|
||||
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
|
||||
end
|
||||
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
|
||||
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
when 7
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
else
|
||||
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
|
||||
end
|
||||
end
|
||||
|
||||
# Wrapper around I18n.l to support custom date formats
|
||||
def format_date(object, format = :default, options = {})
|
||||
date = object.to_date
|
||||
@@ -180,4 +158,12 @@ 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
|
||||
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
|
||||
2
app/helpers/invitations_helper.rb
Normal file
2
app/helpers/invitations_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module InvitationsHelper
|
||||
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 };
|
||||
|
||||
@@ -10,7 +10,8 @@ export default class extends Controller {
|
||||
"bulkEditDrawerTitle",
|
||||
];
|
||||
static values = {
|
||||
resource: String,
|
||||
singularLabel: String,
|
||||
pluralLabel: String,
|
||||
selectedIds: { type: Array, default: [] },
|
||||
};
|
||||
|
||||
@@ -126,15 +127,17 @@ export default class extends Controller {
|
||||
_updateSelectionBar() {
|
||||
const count = this.selectedIdsValue.length;
|
||||
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;
|
||||
this.selectionBarTarget.hidden = count === 0;
|
||||
this.selectionBarTarget.classList.toggle("hidden", count === 0);
|
||||
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
|
||||
count > 0;
|
||||
}
|
||||
|
||||
_pluralizedResourceName() {
|
||||
return `${this.resourceValue}${
|
||||
this.selectedIdsValue.length === 1 ? "" : "s"
|
||||
}`;
|
||||
if (this.selectedIdsValue.length === 1) {
|
||||
return this.singularLabelValue;
|
||||
}
|
||||
|
||||
return this.pluralLabelValue;
|
||||
}
|
||||
|
||||
_updateGroups() {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -535,7 +536,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
get _d3YScale() {
|
||||
const reductionPercent = this.useLabelsValue ? 0.15 : 0.05;
|
||||
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
|
||||
const dataMin = d3.min(this._normalDataPoints, (d) => d.value);
|
||||
const dataMax = d3.max(this._normalDataPoints, (d) => d.value);
|
||||
const padding = (dataMax - dataMin) * reductionPercent;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
18
app/jobs/fetch_security_info_job.rb
Normal file
18
app/jobs/fetch_security_info_job.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class FetchSecurityInfoJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(security_id)
|
||||
return unless Security.security_info_provider.present?
|
||||
|
||||
security = Security.find(security_id)
|
||||
|
||||
security_info_response = Security.security_info_provider.fetch_security_info(
|
||||
ticker: security.ticker,
|
||||
mic_code: security.exchange_mic
|
||||
)
|
||||
|
||||
security.update(
|
||||
name: security_info_response.info.dig("name")
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class SecuritiesImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(country_code = nil)
|
||||
exchanges = StockExchange.in_country(country_code)
|
||||
market_stack_client = Provider::Marketstack.new(ENV["MARKETSTACK_API_KEY"])
|
||||
|
||||
exchanges.each do |exchange|
|
||||
importer = Security::Importer.new(market_stack_client, exchange.mic)
|
||||
importer.import
|
||||
end
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV["EMAIL_SENDER"] if ENV["EMAIL_SENDER"].present?
|
||||
default from: email_address_with_name(ENV.fetch("EMAIL_SENDER", "sender@maybe.local"), "Maybe Finance")
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
11
app/mailers/invitation_mailer.rb
Normal file
11
app/mailers/invitation_mailer.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite_email(invitation)
|
||||
@invitation = invitation
|
||||
@accept_url = accept_invitation_url(@invitation.token)
|
||||
|
||||
mail(
|
||||
to: @invitation.email,
|
||||
subject: t(".subject", inviter: @invitation.inviter.display_name)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,42 +1,36 @@
|
||||
class Account < ApplicationRecord
|
||||
VALUE_MODES = %w[balance transactions]
|
||||
|
||||
include Syncable, Monetizable, Issuable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
validates :mode, inclusion: { in: VALUE_MODES }, allow_nil: 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
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :accountable
|
||||
|
||||
delegate :value, :series, to: :accountable
|
||||
accepts_nested_attributes_for :accountable, update_only: true
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
@@ -61,42 +55,71 @@ class Account < ApplicationRecord
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
transaction do
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created
|
||||
account = new(attributes)
|
||||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
|
||||
# Always initialize an account with a valuation entry to begin tracking value history
|
||||
account.entries.build \
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
account.entries.build(
|
||||
name: "Current Balance",
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
)
|
||||
account.entries.build(
|
||||
name: "Initial Balance",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: 0,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
)
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
account
|
||||
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
|
||||
@@ -125,12 +148,4 @@ class Account < ApplicationRecord
|
||||
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
|
||||
@@ -14,8 +14,22 @@ class Account::Entry < ApplicationRecord
|
||||
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 :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
|
||||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
@@ -29,12 +43,7 @@ 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
|
||||
|
||||
@@ -46,28 +55,18 @@ class Account::Entry < ApplicationRecord
|
||||
amount > 0 && account_transaction?
|
||||
end
|
||||
|
||||
def first_of_type?
|
||||
first_entry = account
|
||||
.entries
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(:date)
|
||||
.first
|
||||
|
||||
first_entry&.id == id
|
||||
end
|
||||
|
||||
def entryable_name_short
|
||||
entryable_type.demodulize.underscore
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
def balance_trend(entries, balances)
|
||||
Account::BalanceTrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
class << self
|
||||
# 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)
|
||||
@@ -205,22 +204,4 @@ class Account::Entry < ApplicationRecord
|
||||
entryable_ids
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_entry
|
||||
@previous_entry ||= account
|
||||
.entries
|
||||
.where("date < ?", date)
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: amount_money,
|
||||
previous: previous_entry&.amount_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
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
|
||||
@@ -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,129 +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_start_dates = {}
|
||||
|
||||
sync_entries.each do |entry|
|
||||
unless ticker_start_dates[entry.account_trade.security.ticker]
|
||||
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
|
||||
end
|
||||
end
|
||||
|
||||
ticker_start_dates.each do |ticker, date|
|
||||
fetched_prices = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
|
||||
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: date, end_date: Date.current, cache: false).run
|
||||
prices[ticker] = gapfilled_prices
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |ticker, holding|
|
||||
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
|
||||
trade_price = trade&.account_trade&.price
|
||||
|
||||
price = get_cached_price(ticker, date) || trade_price
|
||||
|
||||
account.observe_missing_price(ticker:, date:) unless price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
security_id: holding[:security_id],
|
||||
qty: holding[:qty],
|
||||
price: price,
|
||||
amount: price ? (price * holding[:qty]) : nil,
|
||||
currency: holding[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
def generate_next_portfolio(prior_portfolio, trade_entries)
|
||||
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.ticker] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
currency: entry.currency,
|
||||
security_id: trade.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_holdings(holdings)
|
||||
current_time = Time.now
|
||||
holdings_to_upsert = holdings.map do |holding|
|
||||
holding.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
|
||||
end
|
||||
|
||||
def load_prior_portfolio
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.ticker] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
currency: holding.currency,
|
||||
security_id: holding.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(start_date)
|
||||
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
end
|
||||
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.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
|
||||
119
app/models/account/syncer.rb
Normal file
119
app/models/account/syncer.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
class Account::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@start_date = start_date
|
||||
end
|
||||
|
||||
def run
|
||||
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
|
||||
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,7 +5,7 @@ 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
|
||||
@@ -26,6 +26,11 @@ class Account::Trade < ApplicationRecord
|
||||
qty > 0
|
||||
end
|
||||
|
||||
def name
|
||||
prefix = sell? ? "Sell " : "Buy "
|
||||
prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||
end
|
||||
|
||||
def unrealized_gain_loss
|
||||
return nil if sell?
|
||||
current_price = security.current_price
|
||||
|
||||
@@ -1,46 +1,113 @@
|
||||
class Account::TradeBuilder < Account::EntryBuilder
|
||||
class Account::TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[buy sell].freeze
|
||||
|
||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||
|
||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
||||
validates :price, numericality: { greater_than: 0 }
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
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 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
|
||||
account.entries.new(
|
||||
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 security
|
||||
Security.find_or_create_by(ticker: ticker)
|
||||
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
|
||||
|
||||
Account::Transfer.build_from_accounts(
|
||||
from_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,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def amount
|
||||
price.to_d * signed_qty
|
||||
def build_interest
|
||||
account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
_qty = qty.to_d
|
||||
_qty = _qty * -1 if type == "sell"
|
||||
_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
|
||||
ticker_symbol, exchange_mic, exchange_acronym, exchange_country_code = ticker.split("|")
|
||||
|
||||
security = Security.find_or_create_by(ticker: ticker_symbol, exchange_mic: exchange_mic, country_code: exchange_country_code)
|
||||
security.update(exchange_acronym: exchange_acronym)
|
||||
|
||||
FetchSecurityInfoJob.perform_later(security.id)
|
||||
|
||||
security
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,12 +48,20 @@ class Account::Transaction < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
entry.name || "(no description)"
|
||||
end
|
||||
|
||||
def eod_balance
|
||||
entry.amount_money
|
||||
end
|
||||
|
||||
private
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Transfer < ApplicationRecord
|
||||
has_many :entries, dependent: :nullify
|
||||
has_many :entries, dependent: :destroy
|
||||
|
||||
validate :net_zero_flows, if: :single_currency_transfer?
|
||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
||||
@@ -13,17 +13,25 @@ class Account::Transfer < ApplicationRecord
|
||||
end
|
||||
|
||||
def from_name
|
||||
outflow_transaction&.account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
from_account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
inflow_transaction&.account&.name || I18n.t("account/transfer.to_fallback_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
|
||||
@@ -32,31 +40,41 @@ class Account::Transfer < ApplicationRecord
|
||||
entries.find { |e| e.outflow? }
|
||||
end
|
||||
|
||||
def destroy_and_remove_marks!
|
||||
def update_entries!(params)
|
||||
transaction do
|
||||
entries.each do |e|
|
||||
e.update! marked_as_transfer: false
|
||||
entries.each do |entry|
|
||||
entry.update!(params)
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
entries.each(&:sync_account_later)
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
||||
# use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: amount.abs * -1,
|
||||
currency: from_account.currency,
|
||||
amount: converted_amount.amount * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: name,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
|
||||
@@ -18,23 +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([])
|
||||
end
|
||||
|
||||
def mode_required?
|
||||
true
|
||||
def post_sync
|
||||
broadcast_replace_to(
|
||||
account,
|
||||
target: "chart_account_#{account.id}",
|
||||
partial: "accounts/show/chart",
|
||||
locals: { account: account }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,12 +33,6 @@ module Issuable
|
||||
)
|
||||
end
|
||||
|
||||
def observe_missing_price(ticker:, date:)
|
||||
issue = issues.find_or_create_by(type: Issue::PricesMissing.name, resolved_at: nil)
|
||||
issue.append_missing_price(ticker, date)
|
||||
issue.save!
|
||||
end
|
||||
|
||||
def highest_priority_issue
|
||||
issues.active.ordered.first
|
||||
end
|
||||
|
||||
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
|
||||
@@ -10,6 +10,10 @@ module Providable
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def security_info_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def exchange_rates_provider
|
||||
synth_provider
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user