Compare commits
45 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c44464be | ||
|
|
c0908f454a | ||
|
|
e05f03b314 | ||
|
|
6bca35fa22 | ||
|
|
6fa40e0fa2 | ||
|
|
f315370512 | ||
|
|
6e74414cb2 | ||
|
|
9ad04a82cb | ||
|
|
7c878697f4 | ||
|
|
cdb134077d | ||
|
|
65aeab4681 | ||
|
|
e0d2b951d6 | ||
|
|
4eeca00121 | ||
|
|
07a7a6b1aa | ||
|
|
edda5cb35b | ||
|
|
ea8309eedd | ||
|
|
453a54e5e6 | ||
|
|
c70c8b6d86 | ||
|
|
f2a2d2f7e4 | ||
|
|
0a21c92643 | ||
|
|
2c5f647f53 | ||
|
|
11f58537db | ||
|
|
6231814e1e | ||
|
|
7645a9ec56 | ||
|
|
08b59ad5fe | ||
|
|
02adba5280 | ||
|
|
1f5721a8b1 | ||
|
|
7ba9830db5 | ||
|
|
dfc7e1c30c | ||
|
|
76dd5e57fb | ||
|
|
701e17829d | ||
|
|
7c2091b343 | ||
|
|
ef4be7948a | ||
|
|
c8590d53ba | ||
|
|
f62c5e43c3 | ||
|
|
82568b4d8c | ||
|
|
9d006409c2 | ||
|
|
55a085f01f | ||
|
|
23dcdf6e26 | ||
|
|
05e3e689b5 | ||
|
|
01f50dc54c | ||
|
|
5d213f2e6a | ||
|
|
952d847c15 | ||
|
|
e7dc6b88ea | ||
|
|
75ded1c18f |
17
.env.test.example
Normal file
17
.env.test.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
# Uncomment and fill in live keys when you need to generate a VCR cassette fixture
|
||||
# ================
|
||||
|
||||
# SYNTH_API_KEY=<add live key here>
|
||||
|
||||
# ================
|
||||
# Miscellaneous
|
||||
# ================
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
|
||||
@@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
||||
# Layout/SpaceInsideArrayLiteralBrackets:
|
||||
# Enabled: false
|
||||
Layout/ElseAlignment:
|
||||
Enabled: false
|
||||
Layout/EndAlignment:
|
||||
Enabled: false
|
||||
2
Gemfile
2
Gemfile
@@ -52,10 +52,10 @@ group :development, :test do
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "i18n-tasks"
|
||||
gem "erb_lint"
|
||||
gem "dotenv-rails"
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem "dotenv-rails"
|
||||
gem "hotwire-livereload"
|
||||
gem "letter_opener"
|
||||
gem "ruby-lsp-rails"
|
||||
|
||||
102
Gemfile.lock
102
Gemfile.lock
@@ -7,7 +7,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 8035bece705f60e6bddca70ee7d88e935a242bf8
|
||||
revision: 43530b4ac911b8722b8a7ac8025eb9298e1292b4
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.beta3)
|
||||
@@ -35,7 +35,7 @@ GIT
|
||||
activesupport (= 7.2.0.beta3)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
@@ -78,6 +78,7 @@ GIT
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.beta3)
|
||||
actioncable (= 7.2.0.beta3)
|
||||
@@ -109,8 +110,8 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.952.0)
|
||||
aws-sdk-core (3.201.1)
|
||||
aws-partitions (1.961.0)
|
||||
aws-sdk-core (3.201.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
@@ -118,11 +119,11 @@ GEM
|
||||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.156.0)
|
||||
aws-sdk-s3 (1.157.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
@@ -168,32 +169,37 @@ GEM
|
||||
dotenv (= 3.1.2)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.5.0)
|
||||
erb_lint (0.6.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
rainbow
|
||||
rubocop
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faker (3.4.1)
|
||||
faker (3.4.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.10.0)
|
||||
faraday (2.10.1)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
logger
|
||||
faraday-net_http (3.1.0)
|
||||
faraday-net_http (3.1.1)
|
||||
net-http
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.16.3)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
ffi (1.17.0-arm-linux-gnu)
|
||||
ffi (1.17.0-arm64-darwin)
|
||||
ffi (1.17.0-x86-linux-gnu)
|
||||
ffi (1.17.0-x86_64-darwin)
|
||||
ffi (1.17.0-x86_64-linux-gnu)
|
||||
fugit (1.11.0)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.0.3)
|
||||
good_job (4.1.1)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -218,7 +224,7 @@ GEM
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.12.2)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.1)
|
||||
@@ -254,10 +260,10 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.24.1)
|
||||
mocha (2.4.0)
|
||||
mocha (2.4.5)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
@@ -272,27 +278,27 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (8.6.3)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.1.0)
|
||||
pagy (9.0.5)
|
||||
parallel (1.25.1)
|
||||
parser (3.3.4.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.6)
|
||||
pg (1.5.7)
|
||||
prism (0.30.0)
|
||||
propshaft (0.9.0)
|
||||
actionpack (>= 7.0.0)
|
||||
@@ -305,7 +311,7 @@ GEM
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.7)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -340,15 +346,15 @@ GEM
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.0)
|
||||
rexml (3.3.4)
|
||||
strscan
|
||||
rubocop (1.63.5)
|
||||
rubocop (1.65.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
@@ -371,31 +377,33 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.17.7)
|
||||
ruby-lsp (0.17.8)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.10)
|
||||
ruby-lsp-rails (0.3.11)
|
||||
ruby-lsp (>= 0.17.2, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.1)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.22.0)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.23.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.18.1)
|
||||
sentry-rails (5.18.2)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.18.1)
|
||||
sentry-ruby (5.18.1)
|
||||
sentry-ruby (~> 5.18.2)
|
||||
sentry-ruby (5.18.2)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -405,29 +413,29 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11481)
|
||||
sorbet-runtime (0.5.11491)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.6.1)
|
||||
tailwindcss-rails (2.6.5)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
tailwindcss-rails (2.6.5-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
tailwindcss-rails (2.6.5-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
tailwindcss-rails (2.6.5-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
tailwindcss-rails (2.6.5-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
tailwindcss-rails (2.6.5-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.1)
|
||||
timeout (0.4.1)
|
||||
turbo-rails (2.0.5)
|
||||
turbo-rails (2.0.6)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
@@ -447,13 +455,13 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.1)
|
||||
websocket (1.2.10)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.16)
|
||||
zeitwerk (2.6.17)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
/* Reset rules, default styles applied to plain HTML */
|
||||
@layer base {
|
||||
details > summary::-webkit-details-marker {
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked + label + .toggle-switch-dot {
|
||||
input:checked+label+.toggle-switch-dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
|
||||
14
app/controllers/account/cashes_controller.rb
Normal file
14
app/controllers/account/cashes_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
@@ -1,48 +1,15 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
||||
|
||||
def transactions
|
||||
@transaction_entries = @account.entries.account_transactions.reverse_chronological
|
||||
end
|
||||
|
||||
def valuations
|
||||
@valuation_entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
def new
|
||||
@entry = @account.entries.build.tap do |entry|
|
||||
if params[:entryable_type]
|
||||
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
|
||||
else
|
||||
entry.entryable = Account::Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.assign_attributes entry_params
|
||||
@entry.amount = amount if nature.present?
|
||||
@entry.save!
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
@@ -52,6 +19,7 @@ class Account::EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
render entryable_view_path(:show)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -62,6 +30,10 @@ class Account::EntriesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def entryable_view_path(action)
|
||||
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
@@ -70,36 +42,7 @@ class Account::EntriesController < ApplicationController
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_entryable_attributes
|
||||
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
|
||||
|
||||
case entryable_type
|
||||
when "Account::Transaction"
|
||||
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
|
||||
else
|
||||
[ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
|
||||
end
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
entry_params[:amount].to_d.abs * -1
|
||||
else
|
||||
entry_params[:amount].to_d.abs
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
|
||||
def entry_params_with_defaults(params)
|
||||
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
end
|
||||
|
||||
23
app/controllers/account/holdings_controller.rb
Normal file
23
app/controllers/account/holdings_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_holding, only: :show
|
||||
|
||||
def index
|
||||
@holdings = @account.holdings.current
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_holding
|
||||
@holding = @account.holdings.current.find(params[:id])
|
||||
end
|
||||
end
|
||||
34
app/controllers/account/trades_controller.rb
Normal file
34
app/controllers/account/trades_controller.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class Account::TradesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_trades.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[ Account::Trade Account::Transaction ])
|
||||
end
|
||||
|
||||
def create
|
||||
@builder = Account::TradeBuilder.new(entry_params)
|
||||
|
||||
if entry = @builder.save
|
||||
entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:type, :date, :qty, :ticker, :price).merge(account: @account)
|
||||
end
|
||||
end
|
||||
53
app/controllers/account/transactions_controller.rb
Normal file
53
app/controllers/account/transactions_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
|
||||
def index
|
||||
@entries = @account.entries.account_transactions.reverse_chronological
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params.merge(amount: amount))
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:name, :date, :amount, :currency, :entryable_type,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:notes,
|
||||
:excluded,
|
||||
:category_id,
|
||||
:merchant_id,
|
||||
{ tag_ids: [] }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def amount
|
||||
if params[:account_entry][:nature] == "income"
|
||||
entry_params[:amount].to_d * -1
|
||||
else
|
||||
entry_params[:amount].to_d
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
|
||||
|
||||
35
app/controllers/account/valuations_controller.rb
Normal file
35
app/controllers/account/valuations_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_to account_valuations_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ edit show destroy sync update ]
|
||||
@@ -31,7 +31,8 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
@balance_series = @account.series(period: @period)
|
||||
@series = @account.series(period: @period)
|
||||
@trend = @series.trend
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication, Invitable, SelfHostable
|
||||
include AutoSync, Authentication, Invitable, SelfHostable
|
||||
include Pagy::Backend
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
private
|
||||
|
||||
def with_sidebar
|
||||
return "turbo_rails/frame" if turbo_frame_request?
|
||||
|
||||
"with_sidebar"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
@@ -3,13 +3,11 @@ module Authentication
|
||||
|
||||
included do
|
||||
before_action :authenticate_user!
|
||||
after_action :set_last_login_at, if: -> { Current.user }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def skip_authentication(**options)
|
||||
skip_before_action :authenticate_user!, **options
|
||||
skip_after_action :set_last_login_at, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +25,7 @@ module Authentication
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
|
||||
def logout
|
||||
|
||||
13
app/controllers/concerns/auto_sync.rb
Normal file
13
app/controllers/concerns/auto_sync.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module AutoSync
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_family
|
||||
Current.family.sync
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class PagesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class SettingsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TagsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
class TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50")
|
||||
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
|
||||
|
||||
@totals = {
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
|
||||
13
app/helpers/account/cashes_helper.rb
Normal file
13
app/helpers/account/cashes_helper.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Account::CashesHelper
|
||||
def brokerage_cash(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.balance,
|
||||
price: 1,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
||||
@@ -30,10 +30,32 @@ module Account::EntriesHelper
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
def entry_name(entry)
|
||||
if entry.account_trade?
|
||||
trade = entry.account_trade
|
||||
prefix = trade.sell? ? "Sell " : "Buy "
|
||||
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
|
||||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name
|
||||
end
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
|
||||
end.join.html_safe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
permitted_entryable_paths = %w[transaction valuation]
|
||||
permitted_entryable_paths = %w[transaction valuation trade]
|
||||
entry.entryable_name_short.presence_in(permitted_entryable_paths)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,18 +23,38 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) }
|
||||
|
||||
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
end
|
||||
|
||||
def selected_account_tab(account)
|
||||
available_tabs = account_tabs(account)
|
||||
|
||||
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
|
||||
|
||||
tab || available_tabs.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,10 @@ module ApplicationHelper
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
end
|
||||
|
||||
def family_stream
|
||||
turbo_stream_from Current.family if Current.family
|
||||
end
|
||||
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map do |type, message_or_messages|
|
||||
Array(message_or_messages).map do |message|
|
||||
|
||||
@@ -4,6 +4,12 @@ module FormsHelper
|
||||
form_with(**options, &block)
|
||||
end
|
||||
|
||||
def modal_form_wrapper(title:, subtitle: nil, &block)
|
||||
content = capture &block
|
||||
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
|
||||
end
|
||||
|
||||
def form_field_tag(options = {}, &block)
|
||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||
tag.div(**options, &block)
|
||||
@@ -23,17 +29,17 @@ module FormsHelper
|
||||
|
||||
def money_with_currency_field(form, money_method, options = {})
|
||||
render partial: "shared/money_field", locals: {
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
default_currency: options[:default_currency] || "USD",
|
||||
disable_currency: options[:disable_currency] || false,
|
||||
hide_currency: options[:hide_currency] || false,
|
||||
label: options[:label] || "Amount"
|
||||
label: options[:label] || "Amount"
|
||||
}
|
||||
end
|
||||
|
||||
def money_field(form, method, options = {})
|
||||
value = form.object.send(method)
|
||||
value = form.object ? form.object.send(method) : nil
|
||||
|
||||
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
|
||||
|
||||
@@ -42,10 +48,10 @@ module FormsHelper
|
||||
|
||||
money_options = {
|
||||
value: value&.amount,
|
||||
placeholder: 100,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
placeholder: "100",
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = options.merge(money_options)
|
||||
|
||||
@@ -50,6 +50,9 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
end
|
||||
|
||||
def label_html(method, options)
|
||||
options[:label] ? label(method, options[:label], class: "form-field__label") : "".html_safe
|
||||
return label(method, class: "form-field__label") if options[:label] == true
|
||||
return "".html_safe unless options[:label]
|
||||
|
||||
label(method, options[:label], class: "form-field__label")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
module ValueGroupsHelper
|
||||
def value_group_pie_data(value_group)
|
||||
value_group.children
|
||||
.map do |child|
|
||||
{
|
||||
label: to_accountable_title(Accountable.from_type(child.name)),
|
||||
percent_of_total: child.percent_of_total.round(1).to_f,
|
||||
value: child.sum.amount.to_f,
|
||||
currency: child.sum.currency.iso_code,
|
||||
bg_color: accountable_bg_class(child.name),
|
||||
fill_color: accountable_fill_class(child.name)
|
||||
}
|
||||
end
|
||||
.filter { |child| child[:value] > 0 }
|
||||
.to_json
|
||||
value_group.children.filter { |c| c.sum > 0 }.map do |child|
|
||||
{
|
||||
label: to_accountable_title(Accountable.from_type(child.name)),
|
||||
percent_of_total: child.percent_of_total.round(1).to_f,
|
||||
formatted_value: format_money(child.sum, precision: 0),
|
||||
bg_color: accountable_bg_class(child.name),
|
||||
fill_color: accountable_fill_class(child.name)
|
||||
}
|
||||
end.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,6 +59,7 @@ export default class extends Controller {
|
||||
|
||||
deselectAll() {
|
||||
this.selectedIdsValue = []
|
||||
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
|
||||
}
|
||||
|
||||
selectedIdsValueChanged() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as d3 from "d3";
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Array,
|
||||
total: String,
|
||||
label: String,
|
||||
};
|
||||
|
||||
@@ -38,7 +39,7 @@ export default class extends Controller {
|
||||
|
||||
#draw() {
|
||||
this.#d3Container.attr("class", "relative");
|
||||
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
this.#d3Content.html(this.#contentSummaryTemplate());
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
@@ -75,23 +76,17 @@ export default class extends Controller {
|
||||
this.#d3Svg
|
||||
.selectAll(".arc path")
|
||||
.attr("class", (d) => d.data.fill_color);
|
||||
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
this.#d3ContentMemo.html(this.#contentSummaryTemplate());
|
||||
});
|
||||
}
|
||||
|
||||
#contentSummaryTemplate(data) {
|
||||
const total = data.reduce((acc, cur) => acc + cur.value, 0);
|
||||
const currency = data[0].currency;
|
||||
|
||||
return `${this.#currencyValue({
|
||||
value: total,
|
||||
currency,
|
||||
})} <span class="text-xs">${this.labelValue}</span>`;
|
||||
#contentSummaryTemplate() {
|
||||
return `<span class="text-xl text-gray-900 font-medium">${this.totalValue}</span> <span class="text-xs">${this.labelValue}</span>`;
|
||||
}
|
||||
|
||||
#contentDetailTemplate(datum) {
|
||||
return `
|
||||
<span>${this.#currencyValue(datum)}</span>
|
||||
<span class="text-xl text-gray-900 font-medium">${datum.formatted_value}</span>
|
||||
<div class="flex flex-row text-xs gap-2 items-center">
|
||||
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
|
||||
<span>${datum.label}</span>
|
||||
@@ -100,21 +95,6 @@ export default class extends Controller {
|
||||
`;
|
||||
}
|
||||
|
||||
#currencyValue(datum) {
|
||||
const formattedValue = Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(datum.value);
|
||||
|
||||
const firstDigitIndex = formattedValue.search(/\d/);
|
||||
const currencyPrefix = formattedValue.substring(0, firstDigitIndex);
|
||||
const mainPart = formattedValue.substring(firstDigitIndex);
|
||||
const [integerPart, fractionalPart] = mainPart.split(".");
|
||||
|
||||
return `<p class="text-gray-500 -space-x-0.5">${currencyPrefix}<span class="text-xl text-gray-900 font-medium">${integerPart}</span>.${fractionalPart}</p>`;
|
||||
}
|
||||
|
||||
get #radius() {
|
||||
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
|
||||
}
|
||||
|
||||
74
app/javascript/controllers/tooltip_controller.js
Normal file
74
app/javascript/controllers/tooltip_controller.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
offset,
|
||||
arrow
|
||||
} from '@floating-ui/dom';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["arrow", "tooltip"];
|
||||
static values = {
|
||||
placement: { type: String, default: "top" },
|
||||
offset: { type: Number, default: 10 },
|
||||
crossAxis: { type: Number, default: 0 },
|
||||
alignmentAxis: { type: Number, default: null },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener("mouseenter", this.showTooltip);
|
||||
this.element.addEventListener("mouseleave", this.hideTooltip);
|
||||
this.element.addEventListener("focus", this.showTooltip);
|
||||
this.element.addEventListener("blur", this.hideTooltip);
|
||||
};
|
||||
|
||||
showTooltip = () => {
|
||||
this.tooltipTarget.style.display = 'block';
|
||||
this.#update();
|
||||
};
|
||||
|
||||
hideTooltip = () => {
|
||||
this.tooltipTarget.style.display = '';
|
||||
};
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("mouseenter", this.showTooltip);
|
||||
this.element.removeEventListener("mouseleave", this.hideTooltip);
|
||||
this.element.removeEventListener("focus", this.showTooltip);
|
||||
this.element.removeEventListener("blur", this.hideTooltip);
|
||||
};
|
||||
|
||||
#update() {
|
||||
computePosition(this.element, this.tooltipTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [
|
||||
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
|
||||
flip(),
|
||||
shift({ padding: 5 }),
|
||||
arrow({ element: this.arrowTarget }),
|
||||
],
|
||||
}).then(({ x, y, placement, middlewareData }) => {
|
||||
Object.assign(this.tooltipTarget.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
|
||||
const { x: arrowX, y: arrowY } = middlewareData.arrow;
|
||||
const staticSide = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[placement.split('-')[0]];
|
||||
|
||||
Object.assign(this.arrowTarget.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : '',
|
||||
top: arrowY != null ? `${arrowY}px` : '',
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticSide]: '-4px',
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class Account < ApplicationRecord
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency)
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
@@ -73,6 +73,31 @@ class Account < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Start of temporary fix for #1068
|
||||
# ==========================================================================
|
||||
|
||||
# TODO: Both `series` and `value` methods are a temporary fix for #1068, which appears to be a data corruption issue.
|
||||
# Every account should have an accountable no matter what, but some self hosted instances seem to have missing accountables.
|
||||
# When this is fixed, we can add this back to `delegate :value, :series, to: :accountable`
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
if accountable.present?
|
||||
accountable.series(period: period, currency: currency)
|
||||
else
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
def value
|
||||
if accountable.present?
|
||||
accountable.value
|
||||
else
|
||||
balance_money
|
||||
end
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# End of temporary fix for #1068
|
||||
|
||||
def alert
|
||||
latest_sync = syncs.latest
|
||||
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
|
||||
@@ -82,18 +107,6 @@ class Account < ApplicationRecord
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.account_valuations.find_by(date: Date.current)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class Account::Entry < ApplicationRecord
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
validate :trade_valid?, if: -> { account_trade? }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
@@ -64,6 +65,11 @@ class Account::Entry < ApplicationRecord
|
||||
end
|
||||
|
||||
class << self
|
||||
# arbitrary cutoff date to avoid expensive sync operations
|
||||
def min_supported_date
|
||||
10.years.ago.to_date
|
||||
end
|
||||
|
||||
def daily_totals(entries, currency, period: Period.last_30_days)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
@@ -198,7 +204,7 @@ class Account::Entry < ApplicationRecord
|
||||
current_qty = account.holding_qty(account_trade.security)
|
||||
|
||||
if current_qty < account_trade.qty.abs
|
||||
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.symbol} because you only own #{current_qty} shares")
|
||||
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.ticker} because you only own #{current_qty} shares")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
class Account::Holding < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, :currency, presence: true
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :current, -> { where(date: Date.current).order(amount: :desc) }
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :known_value, -> { where.not(amount: nil) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
|
||||
delegate :name, to: :security
|
||||
delegate :ticker, to: :security
|
||||
|
||||
def weight
|
||||
return nil unless amount
|
||||
|
||||
portfolio_value = account.holdings.current.known_value.sum(&:amount)
|
||||
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
|
||||
end
|
||||
|
||||
# Basic approximation of cost-basis
|
||||
def avg_cost
|
||||
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
|
||||
Money.new(avg_cost, currency)
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= calculate_trend
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_trend
|
||||
return nil unless amount_money
|
||||
|
||||
start_amount = qty * avg_cost
|
||||
|
||||
TimeSeries::Trend.new \
|
||||
current: amount_money,
|
||||
previous: start_amount
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,20 +32,50 @@ class Account::Holding::Syncer
|
||||
.order(:date)
|
||||
end
|
||||
|
||||
def get_cached_price(ticker, date)
|
||||
return nil unless security_prices.key?(ticker)
|
||||
|
||||
price = security_prices[ticker].find { |p| p.date == date }
|
||||
price ? price[:price] : nil
|
||||
end
|
||||
|
||||
def security_prices
|
||||
@security_prices ||= begin
|
||||
prices = {}
|
||||
ticker_start_dates = {}
|
||||
|
||||
sync_entries.each do |entry|
|
||||
unless ticker_start_dates[entry.account_trade.security.ticker]
|
||||
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
|
||||
end
|
||||
end
|
||||
|
||||
ticker_start_dates.each do |ticker, date|
|
||||
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |isin, holding|
|
||||
price = Security::Price.find_by!(date: date, isin: isin).price
|
||||
@portfolio.map do |ticker, holding|
|
||||
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
|
||||
trade_price = trade&.account_trade&.price
|
||||
|
||||
price = get_cached_price(ticker, date) || trade_price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
security_id: holding[:security_id],
|
||||
qty: holding[:qty],
|
||||
price: price,
|
||||
amount: price * holding[:qty]
|
||||
amount: price ? (price * holding[:qty]) : nil,
|
||||
currency: holding[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,13 +84,14 @@ class Account::Holding::Syncer
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.isin, :qty) || 0
|
||||
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.isin] = {
|
||||
new_portfolio[trade.security.ticker] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
currency: entry.currency,
|
||||
security_id: trade.security_id
|
||||
}
|
||||
end
|
||||
@@ -81,10 +112,11 @@ class Account::Holding::Syncer
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.isin] = {
|
||||
@portfolio[holding.security.ticker] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
currency: holding.currency,
|
||||
security_id: holding.security_id
|
||||
}
|
||||
end
|
||||
|
||||
@@ -78,5 +78,6 @@ class Account::Sync < ApplicationRecord
|
||||
partial: "shared/notification",
|
||||
locals: { type: type, message: message }
|
||||
)
|
||||
account.family.broadcast_refresh
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,14 @@ module Account::Syncable
|
||||
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
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
class Account::Trade < ApplicationRecord
|
||||
include Account::Entryable
|
||||
include Account::Entryable, Monetizable
|
||||
|
||||
monetize :price
|
||||
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, presence: true, numericality: { other_than: 0 }
|
||||
validates :price, presence: true
|
||||
validates :price, :currency, presence: true
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
|
||||
46
app/models/account/trade_builder.rb
Normal file
46
app/models/account/trade_builder.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class Account::TradeBuilder
|
||||
TYPES = %w[ buy sell ].freeze
|
||||
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||
|
||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
||||
validates :price, numericality: { greater_than: 0 }
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_entry
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_entry
|
||||
account.entries.account_trades.create! \
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: account.currency,
|
||||
entryable: Account::Trade.new(
|
||||
security: security,
|
||||
qty: signed_qty,
|
||||
price: price.to_d,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
|
||||
def security
|
||||
Security.find_or_create_by(ticker: ticker)
|
||||
end
|
||||
|
||||
def amount
|
||||
price.to_d * signed_qty
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
_qty = qty.to_d
|
||||
_qty = _qty * -1 if type == "sell"
|
||||
_qty
|
||||
end
|
||||
end
|
||||
@@ -9,19 +9,18 @@ class Account::Transfer < ApplicationRecord
|
||||
end
|
||||
|
||||
def amount_money
|
||||
entries.first&.amount_money&.abs
|
||||
entries.first&.amount_money&.abs || Money.new(0)
|
||||
end
|
||||
|
||||
def from_name
|
||||
outflow_transaction&.account&.name
|
||||
outflow_transaction&.account&.name || I18n.t("account.transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
inflow_transaction&.account&.name
|
||||
inflow_transaction&.account&.name || I18n.t("account.transfer.to_fallback_name")
|
||||
end
|
||||
|
||||
def name
|
||||
return nil unless from_name && to_name
|
||||
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
|
||||
@@ -17,4 +17,20 @@ module Accountable
|
||||
included do
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
def value
|
||||
account.balance_money
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,18 +6,25 @@ module Providable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def exchange_rates_provider
|
||||
api_key = ENV["SYNTH_API_KEY"]
|
||||
def security_prices_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
if api_key.present?
|
||||
Provider::Synth.new api_key
|
||||
else
|
||||
nil
|
||||
end
|
||||
def exchange_rates_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def git_repository_provider
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def synth_provider
|
||||
@synth_provider ||= begin
|
||||
api_key = ENV["SYNTH_API_KEY"]
|
||||
api_key.present? ? Provider::Synth.new(api_key) : nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,10 +34,6 @@ class Demo::Generator
|
||||
create_car_and_loan!
|
||||
|
||||
puts "accounts created"
|
||||
|
||||
family.sync
|
||||
|
||||
puts "balances synced"
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
end
|
||||
@@ -56,6 +52,7 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def clear_data!
|
||||
User.find_by_email("user@maybe.local")&.destroy
|
||||
ExchangeRate.destroy_all
|
||||
Security.destroy_all
|
||||
Security::Price.destroy_all
|
||||
@@ -169,10 +166,13 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def load_securities!
|
||||
# Create an unknown security to simulate edge cases
|
||||
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock"
|
||||
|
||||
securities = [
|
||||
{ isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 },
|
||||
{ isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
|
||||
{ isin: "US5949181045", symbol: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
|
||||
{ ticker: "AAPL", name: "Apple Inc.", reference_price: 210 },
|
||||
{ ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
|
||||
{ ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
|
||||
]
|
||||
|
||||
securities.each do |security_attributes|
|
||||
@@ -184,7 +184,7 @@ class Demo::Generator
|
||||
low_price = reference - 20
|
||||
high_price = reference + 20
|
||||
Security::Price.create! \
|
||||
isin: security.isin,
|
||||
ticker: security.ticker,
|
||||
date: date,
|
||||
price: Faker::Number.positive(from: low_price, to: high_price)
|
||||
end
|
||||
@@ -201,9 +201,13 @@ class Demo::Generator
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||
|
||||
aapl = Security.find_by(symbol: "AAPL")
|
||||
tm = Security.find_by(symbol: "TM")
|
||||
msft = Security.find_by(symbol: "MSFT")
|
||||
aapl = Security.find_by(ticker: "AAPL")
|
||||
tm = Security.find_by(ticker: "TM")
|
||||
msft = Security.find_by(ticker: "MSFT")
|
||||
unknown = Security.find_by(ticker: "UNKNOWN")
|
||||
|
||||
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
|
||||
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
|
||||
|
||||
trades = [
|
||||
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
||||
@@ -216,15 +220,15 @@ class Demo::Generator
|
||||
date = Faker::Number.positive(to: 730).days.ago.to_date
|
||||
security = trade[:security]
|
||||
qty = trade[:qty]
|
||||
price = Security::Price.find_by!(isin: security.isin, date: date).price
|
||||
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
|
||||
name_prefix = qty < 0 ? "Sell " : "Buy "
|
||||
|
||||
account.entries.create! \
|
||||
date: date,
|
||||
amount: qty * price,
|
||||
currency: "USD",
|
||||
name: name_prefix + "#{qty} shares of #{security.symbol}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, security: security)
|
||||
name: name_prefix + "#{qty} shares of #{security.ticker}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@ class ExchangeRate < ApplicationRecord
|
||||
|
||||
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
|
||||
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a.to_set
|
||||
existing_dates = rates.map(&:date).to_set
|
||||
all_dates = (start_date..end_date).to_a
|
||||
existing_dates = rates.map(&:date)
|
||||
missing_dates = all_dates - existing_dates
|
||||
|
||||
if missing_dates.any?
|
||||
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
|
||||
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
|
||||
end
|
||||
|
||||
rates
|
||||
|
||||
@@ -6,12 +6,31 @@ module ExchangeRate::Provided
|
||||
class_methods do
|
||||
private
|
||||
|
||||
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
|
||||
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
|
||||
return [] unless exchange_rates_provider.present?
|
||||
|
||||
dates.map do |date|
|
||||
fetch_rate_from_provider from:, to:, date:, cache:
|
||||
end.compact
|
||||
response = exchange_rates_provider.fetch_exchange_rates \
|
||||
from: from,
|
||||
to: to,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
|
||||
if response.success?
|
||||
response.rates.map do |exchange_rate|
|
||||
rate = ExchangeRate.new \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: exchange_rate.dig(:date).to_date,
|
||||
rate: exchange_rate.dig(:rate)
|
||||
|
||||
rate.save! if cache
|
||||
rate
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_rate_from_provider(from:, to:, date:, cache: false)
|
||||
|
||||
@@ -105,6 +105,16 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
accounts.active.sync(start_date: start_date)
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync_later(start_date: start_date || account.last_sync_date)
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def needs_sync?
|
||||
last_synced_at.nil? || last_synced_at.to_date < Date.current
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,4 +13,35 @@ class Investment < ApplicationRecord
|
||||
[ "Roth 401k", "roth_401k" ],
|
||||
[ "Angel", "angel" ]
|
||||
].freeze
|
||||
|
||||
def value
|
||||
account.balance_money + holdings_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
|
||||
|
||||
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
combined_series = balance_series.map do |balance|
|
||||
holding_amount = holdings_by_date[balance.date] || 0
|
||||
|
||||
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
|
||||
end
|
||||
|
||||
if combined_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.new(combined_series)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,10 +5,35 @@ class Provider::Synth
|
||||
@api_key = api_key
|
||||
end
|
||||
|
||||
def fetch_security_prices(ticker:, start_date:, end_date:)
|
||||
prices = paginate(
|
||||
"#{base_url}/tickers/#{ticker}/open-close",
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
) do |body|
|
||||
body.dig("prices").map do |price|
|
||||
{
|
||||
date: price.dig("date"),
|
||||
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
|
||||
currency: "USD"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
SecurityPriceResponse.new \
|
||||
prices: prices,
|
||||
success?: true,
|
||||
raw_response: prices.to_json
|
||||
rescue StandardError => error
|
||||
SecurityPriceResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
retrying Provider::Base.known_transient_errors do |on_last_attempt|
|
||||
response = Faraday.get("#{base_url}/rates/historical") do |req|
|
||||
req.headers["Authorization"] = "Bearer #{api_key}"
|
||||
response = client.get("#{base_url}/rates/historical") do |req|
|
||||
req.params["date"] = date.to_s
|
||||
req.params["from"] = from
|
||||
req.params["to"] = to
|
||||
@@ -32,20 +57,100 @@ class Provider::Synth
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
exchange_rates = paginate(
|
||||
"#{base_url}/rates/historical-range",
|
||||
from: from,
|
||||
to: to,
|
||||
date_start: start_date.to_s,
|
||||
date_end: end_date.to_s
|
||||
) do |body|
|
||||
body.dig("data").map do |exchange_rate|
|
||||
{
|
||||
date: exchange_rate.dig("date"),
|
||||
rate: exchange_rate.dig("rates", to)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
ExchangeRatesResponse.new \
|
||||
rates: exchange_rates,
|
||||
success?: true,
|
||||
raw_response: exchange_rates.to_json
|
||||
rescue StandardError => error
|
||||
ExchangeRatesResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key
|
||||
|
||||
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
|
||||
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
|
||||
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
|
||||
|
||||
def base_url
|
||||
"https://api.synthfinance.com"
|
||||
end
|
||||
|
||||
def app_name
|
||||
"maybe_app"
|
||||
end
|
||||
|
||||
def app_type
|
||||
Rails.application.config.app_mode
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Faraday.new(url: base_url) do |faraday|
|
||||
faraday.headers["Authorization"] = "Bearer #{api_key}"
|
||||
faraday.headers["X-Source"] = app_name
|
||||
faraday.headers["X-Source-Type"] = app_type
|
||||
end
|
||||
end
|
||||
|
||||
def build_error(response)
|
||||
Provider::Base::ProviderError.new(<<~ERROR)
|
||||
Failed to fetch exchange rate from #{self.class}
|
||||
Failed to fetch data from #{self.class}
|
||||
Status: #{response.status}
|
||||
Body: #{response.body.inspect}
|
||||
ERROR
|
||||
end
|
||||
|
||||
def fetch_page(url, page, params = {})
|
||||
client.get(url) do |req|
|
||||
req.headers["Authorization"] = "Bearer #{api_key}"
|
||||
params.each { |k, v| req.params[k.to_s] = v.to_s }
|
||||
req.params["page"] = page
|
||||
end
|
||||
end
|
||||
|
||||
def paginate(url, params = {})
|
||||
results = []
|
||||
page = 1
|
||||
current_page = 0
|
||||
total_pages = 1
|
||||
|
||||
while current_page < total_pages
|
||||
response = fetch_page(url, page, params)
|
||||
|
||||
if response.success?
|
||||
body = JSON.parse(response.body)
|
||||
page_results = yield(body)
|
||||
results.concat(page_results)
|
||||
|
||||
current_page = body.dig("paging", "current_page")
|
||||
total_pages = body.dig("paging", "total_pages")
|
||||
|
||||
page += 1
|
||||
else
|
||||
raise build_error(response)
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
class Security < ApplicationRecord
|
||||
before_save :normalize_identifiers
|
||||
before_save :upcase_ticker
|
||||
|
||||
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
|
||||
|
||||
validates :isin, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :ticker, presence: true, uniqueness: { case_sensitive: false }
|
||||
|
||||
private
|
||||
|
||||
def normalize_identifiers
|
||||
self.isin = isin.upcase
|
||||
self.symbol = symbol.upcase
|
||||
def upcase_ticker
|
||||
self.ticker = ticker.upcase
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +1,34 @@
|
||||
class Security::Price < ApplicationRecord
|
||||
include Provided
|
||||
|
||||
before_save :upcase_ticker
|
||||
|
||||
validates :ticker, presence: true, uniqueness: { scope: :date, case_sensitive: false }
|
||||
|
||||
class << self
|
||||
def find_price(ticker:, date:, cache: true)
|
||||
result = find_by(ticker:, date:)
|
||||
|
||||
result || fetch_price_from_provider(ticker:, date:, cache:)
|
||||
end
|
||||
|
||||
def find_prices(ticker:, start_date:, end_date: Date.current, cache: true)
|
||||
prices = where(ticker:, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a.to_set
|
||||
existing_dates = prices.map(&:date).to_set
|
||||
missing_dates = (all_dates - existing_dates).sort
|
||||
|
||||
if missing_dates.any?
|
||||
prices += fetch_prices_from_provider(ticker:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def upcase_ticker
|
||||
self.ticker = ticker.upcase
|
||||
end
|
||||
end
|
||||
|
||||
55
app/models/security/price/provided.rb
Normal file
55
app/models/security/price/provided.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
module Security::Price::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Providable
|
||||
|
||||
class_methods do
|
||||
private
|
||||
|
||||
def fetch_price_from_provider(ticker:, date:, cache: false)
|
||||
return nil unless security_prices_provider.present?
|
||||
|
||||
response = security_prices_provider.fetch_security_prices \
|
||||
ticker: ticker,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
|
||||
if response.success? && response.prices.size > 0
|
||||
price = Security::Price.new \
|
||||
ticker: ticker,
|
||||
date: response.prices.first[:date],
|
||||
price: response.prices.first[:price],
|
||||
currency: response.prices.first[:currency]
|
||||
|
||||
price.save! if cache
|
||||
price
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_prices_from_provider(ticker:, start_date:, end_date:, cache: false)
|
||||
return [] unless security_prices_provider.present?
|
||||
|
||||
response = security_prices_provider.fetch_security_prices \
|
||||
ticker: ticker,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
|
||||
if response.success?
|
||||
response.prices.map do |price|
|
||||
new_price = Security::Price.new \
|
||||
ticker: ticker,
|
||||
date: price[:date],
|
||||
price: price[:price],
|
||||
currency: price[:currency]
|
||||
|
||||
new_price.save! if cache
|
||||
new_price
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -44,7 +44,7 @@ class TimeSeries::Trend
|
||||
end
|
||||
|
||||
def percent
|
||||
if previous.nil?
|
||||
if previous.nil? || (previous.zero? && current.zero?)
|
||||
0.0
|
||||
elsif previous.zero?
|
||||
Float::INFINITY
|
||||
|
||||
21
app/views/account/cashes/_cash.html.erb
Normal file
21
app/views/account/cashes/_cash.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# locals: (holding:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-9 flex items-center gap-4">
|
||||
<%= render "shared/circle_logo", name: holding.name %>
|
||||
<div>
|
||||
<%= tag.p holding.name %>
|
||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 text-right">
|
||||
<% if holding.amount_money %>
|
||||
<%= tag.p format_money holding.amount_money %>
|
||||
<% else %>
|
||||
<%= tag.p "?", class: "text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
18
app/views/account/cashes/index.html.erb
Normal file
18
app/views/account/cashes/index.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%= turbo_frame_tag dom_id(@account, "cash") do %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".cash"), class: "font-medium text-lg" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
|
||||
<%= tag.p t(".name"), class: "col-span-9" %>
|
||||
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,4 +1,5 @@
|
||||
<%# locals: (entry:, **opts) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %>
|
||||
<%= render partial: entry.entryable.to_partial_path, locals: { entry: entry, **opts } %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# locals: (date:, entries:, selectable: true, **opts) %>
|
||||
<%# locals: (date:, entries:, content:, selectable:) %>
|
||||
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
@@ -15,7 +15,6 @@
|
||||
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
|
||||
<%= render transfer_entries(entries), selectable:, **opts %>
|
||||
<%= content %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||
<%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %>
|
||||
<% end %>
|
||||
@@ -1 +0,0 @@
|
||||
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
|
||||
@@ -1,2 +0,0 @@
|
||||
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
|
||||
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
||||
@@ -1 +0,0 @@
|
||||
<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %>
|
||||
@@ -1,3 +0,0 @@
|
||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||
<%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %>
|
||||
<% end %>
|
||||
@@ -1 +0,0 @@
|
||||
<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %>
|
||||
45
app/views/account/holdings/_holding.html.erb
Normal file
45
app/views/account/holdings/_holding.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<%# locals: (holding:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<%= render "shared/circle_logo", name: holding.name || "H" %>
|
||||
<div>
|
||||
<%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||
<% if holding.weight %>
|
||||
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
|
||||
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
||||
<% else %>
|
||||
<%= tag.p "?", class: "text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p format_money holding.avg_cost %>
|
||||
<%= tag.p t(".per_share"), class: "font-normal text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<% if holding.amount_money %>
|
||||
<%= tag.p format_money holding.amount_money %>
|
||||
<% else %>
|
||||
<%= tag.p "?", class: "text-gray-500" %>
|
||||
<% end %>
|
||||
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<% if holding.trend %>
|
||||
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
||||
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
||||
<% else %>
|
||||
<%= tag.p "?", class: "text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
1
app/views/account/holdings/_ruler.html.erb
Normal file
1
app/views/account/holdings/_ruler.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>
|
||||
37
app/views/account/holdings/index.html.erb
Normal file
37
app/views/account/holdings/index.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<%= turbo_frame_tag dom_id(@account, "holdings") do %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
|
||||
<%= link_to new_account_trade_path(@account),
|
||||
id: dom_id(@account, "new_trade"),
|
||||
data: { turbo_frame: :modal },
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
<%= tag.span t(".new_holding"), class: "text-sm" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
|
||||
<%= tag.p t(".name"), class: "col-span-4" %>
|
||||
<%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %>
|
||||
<%= tag.p t(".cost"), class: "col-span-2 justify-self-end" %>
|
||||
<%= tag.p t(".holdings"), class: "col-span-2 justify-self-end" %>
|
||||
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<% if @holdings.any? %>
|
||||
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
|
||||
<% elsif @account.needs_sync? || true %>
|
||||
<div class="flex flex-col justify-center items-center pt-4 pb-8">
|
||||
<p class="text-gray-500 p-4"><%= t(".needs_sync") %></p>
|
||||
<%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
1
app/views/account/holdings/new.html.erb
Normal file
1
app/views/account/holdings/new.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<p>Coming soon...</p>
|
||||
45
app/views/account/holdings/show.html.erb
Normal file
45
app/views/account/holdings/show.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<%= drawer do %>
|
||||
<div class="space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<div>
|
||||
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %>
|
||||
<%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<%= render "shared/circle_logo", name: @holding.name %>
|
||||
</header>
|
||||
|
||||
<details class="group space-y-2">
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".overview") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div>
|
||||
<p class="pl-4 text-gray-500">Coming soon...</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2">
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".history") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div>
|
||||
<p class="pl-4 text-gray-500">Coming soon...</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2">
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".settings") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div>
|
||||
<p class="pl-4 text-gray-500">Coming soon...</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<% end %>
|
||||
19
app/views/account/trades/_form.html.erb
Normal file
19
app/views/account/trades/_form.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<%= styled_form_with data: { turbo_frame: "_top" },
|
||||
scope: :account_entry,
|
||||
url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell]], "buy"), label: t(".type") %>
|
||||
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
|
||||
<%= form.date_field :date, label: true %>
|
||||
<%= form.hidden_field :currency, value: entry.account.currency %>
|
||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
|
||||
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
|
||||
<%= form.hidden_field :currency, value: entry.account.currency %>
|
||||
</div>
|
||||
|
||||
<%= form.submit t(".submit") %>
|
||||
</div>
|
||||
<% end %>
|
||||
15
app/views/account/trades/_selection_bar.html.erb
Normal file
15
app/views/account/trades/_selection_bar.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
|
||||
|
||||
<p data-bulk-select-target="selectionBarText"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
50
app/views/account/trades/_trade.html.erb
Normal file
50
app/views/account/trades/_trade.html.erb
Normal file
@@ -0,0 +1,50 @@
|
||||
<%# locals: (entry:, selectable: true, **opts) %>
|
||||
|
||||
<% trade, account = entry.account_trade, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="pr-10 flex items-center gap-4 col-span-6">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-full">
|
||||
<%= tag.div class: ["flex items-center gap-2"] do %>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= entry_name(entry).first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry_name(entry) %>
|
||||
<% else %>
|
||||
<%= link_to entry_name(entry),
|
||||
account_entry_path(account, entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-1 col-span-3">
|
||||
<% if entry.account_transaction? && entry.marked_as_transfer? %>
|
||||
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
|
||||
<% elsif entry.account_transaction? %>
|
||||
<%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %>
|
||||
<% else %>
|
||||
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex items-center justify-end">
|
||||
<% if entry.account_transaction? %>
|
||||
<%= tag.p format_money(entry.amount_money), class: { "text-green-500": entry.inflow? } %>
|
||||
<% else %>
|
||||
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
42
app/views/account/trades/index.html.erb
Normal file
42
app/views/account/trades/index.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%= turbo_frame_tag dom_id(@account, "trades") do %>
|
||||
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="font-medium text-lg"><%= t(".trades") %></h3>
|
||||
<%= link_to new_account_trade_path(@account),
|
||||
id: dom_id(@account, "new_trade"),
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
<span class="text-sm"><%= t(".new") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-25 rounded-xl grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-5 py-3">
|
||||
<div class="pl-0.5 col-span-6 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<%= tag.p t(".trade") %>
|
||||
</div>
|
||||
|
||||
<%= tag.p t(".type"), class: "col-span-3 justify-self-end" %>
|
||||
<%= tag.p t(".amount"), class: "col-span-3 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
||||
<%= render "selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @entries.empty? %>
|
||||
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
3
app/views/account/trades/new.html.erb
Normal file
3
app/views/account/trades/new.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= modal_form_wrapper title: t(".title") do %>
|
||||
<%= render "account/trades/form", entry: @entry %>
|
||||
<% end %>
|
||||
29
app/views/account/trades/show.html.erb
Normal file
29
app/views/account/trades/show.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<% entry = @entry %>
|
||||
|
||||
<%= drawer do %>
|
||||
<div>
|
||||
<header class="mb-4 space-y-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
|
||||
<span class="text-lg text-gray-500"><%= entry.currency %></span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
|
||||
</header>
|
||||
|
||||
<div class="space-y-2">
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".overview") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6 pl-4 text-gray-500">
|
||||
<p>Details coming soon...</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -2,7 +2,7 @@
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<% name_col_span = entry.marked_as_transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
@@ -4,7 +4,7 @@
|
||||
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
|
||||
<%= link_to new_transaction_path(account_id: @account),
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
<span class="text-sm"><%= t(".new") %></span>
|
||||
<% end %>
|
||||
@@ -15,12 +15,12 @@
|
||||
<%= render "selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @transaction_entries.empty? %>
|
||||
<% if @entries.empty? %>
|
||||
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
|
||||
<%= render "entry_group", date:, entries: entries %>
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<%= render entries %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,6 +1,4 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
|
||||
|
||||
<%= drawer do %>
|
||||
<div>
|
||||
@@ -27,7 +25,7 @@
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<div class="flex space-x-2">
|
||||
@@ -60,15 +58,15 @@
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.select :tag_ids,
|
||||
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids),
|
||||
{
|
||||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
class: "placeholder:text-gray-500"
|
||||
label: t(".tags_label"),
|
||||
class: "placeholder:text-gray-500"
|
||||
},
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %>
|
||||
@@ -84,7 +82,7 @@
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
@@ -110,8 +108,8 @@
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,4 +1,11 @@
|
||||
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<% if transfer.errors.present? %>
|
||||
<div class="text-red-600 flex items-center gap-2">
|
||||
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
||||
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<section>
|
||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<%= tag.div class: short ? "max-w-[250px]" : "max-w-[325px]" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= circle_logo("T") %>
|
||||
<%= circle_logo(transfer.from_name[0].upcase) %>
|
||||
|
||||
<%= tag.p transfer.name, class: "truncate text-gray-900" %>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
||||
<header class="flex justify-between">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-xl" %>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<% if @transfer.errors.present? %>
|
||||
<div class="text-red-600 flex items-center gap-2">
|
||||
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
||||
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "form", transfer: @transfer %>
|
||||
</article>
|
||||
<%= modal_form_wrapper title: t(".title") do %>
|
||||
<%= render "form", transfer: @transfer %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<%= form_with model: [entry.account, entry],
|
||||
data: { turbo_frame: "_top" },
|
||||
url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry) do |f| %>
|
||||
url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry) do |f| %>
|
||||
<div class="grid grid-cols-10 p-4 items-center">
|
||||
<div class="col-span-7 flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
|
||||
<%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-between gap-2">
|
||||
<%= f.date_field :date, required: "required", max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
|
||||
<%= f.date_field :date, required: "required", min: Account::Entry.min_supported_date, max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
|
||||
<%= f.number_field :amount, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
|
||||
<%= f.hidden_field :currency, value: entry.account.currency %>
|
||||
<%= f.hidden_field :entryable_type, value: entry.entryable_type %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex gap-2 justify-end items-center">
|
||||
<%= link_to t(".cancel"), valuation_account_entries_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
|
||||
<%= link_to t(".cancel"), account_valuations_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
|
||||
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
3
app/views/account/valuations/edit.html.erb
Normal file
3
app/views/account/valuations/edit.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||
<%= render "account/valuations/form", entry: @entry %>
|
||||
<% end %>
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
|
||||
<%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"),
|
||||
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
|
||||
<%= link_to new_account_valuation_path(@account),
|
||||
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
<%= tag.span t(".new_entry"), class: "text-sm" %>
|
||||
@@ -21,11 +21,11 @@
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %>
|
||||
|
||||
<% if @valuation_entries.any? %>
|
||||
<%= render partial: "account/entries/entryables/valuation/valuation",
|
||||
collection: @valuation_entries,
|
||||
as: :entry,
|
||||
spacer_template: "ruler" %>
|
||||
<% if @entries.any? %>
|
||||
<%= render partial: "account/valuations/valuation",
|
||||
collection: @entries,
|
||||
as: :entry,
|
||||
spacer_template: "account/entries/ruler" %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_valuations") %></p>
|
||||
<% end %>
|
||||
4
app/views/account/valuations/new.html.erb
Normal file
4
app/views/account/valuations/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||
<%= render "account/valuations/form", entry: @entry %>
|
||||
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
||||
<% end %>
|
||||
3
app/views/account/valuations/show.html.erb
Normal file
3
app/views/account/valuations/show.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<% entry = @entry %>
|
||||
|
||||
<%= render "account/valuations/valuation", entry: entry %>
|
||||
26
app/views/accounts/_tooltip.html.erb
Normal file
26
app/views/accounts/_tooltip.html.erb
Normal file
@@ -0,0 +1,26 @@
|
||||
<%# locals: (account:) -%>
|
||||
<div data-controller="tooltip" data-tooltip-target="element" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
|
||||
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
|
||||
<div id="tooltip" role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
|
||||
<div class="text-white">
|
||||
<%= t(".total_value_tooltip") %>
|
||||
</div>
|
||||
<div class="flex pt-3">
|
||||
<div class="text-gray-300">
|
||||
<%= t(".holdings") %>
|
||||
</div>
|
||||
<div class="text-white ml-auto">
|
||||
<%= tag.p format_money(account.investment.holdings_value, precision: 0) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="text-gray-300">
|
||||
<%= t(".cash") %>
|
||||
</div>
|
||||
<div class="text-white ml-auto">
|
||||
<%= tag.p format_money(account.balance_money, precision: 0) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-tooltip-target="arrow"></div>
|
||||
</div>
|
||||
@@ -1,22 +1,15 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".edit", account: @account.name) %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
|
||||
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name") %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
|
||||
|
||||
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name") %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
|
||||
<div class="relative">
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
<%= link_to new_institution_path do %>
|
||||
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
<%= link_to new_institution_path do %>
|
||||
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= f.submit %>
|
||||
<% end %>
|
||||
</article>
|
||||
<%= f.submit %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %>
|
||||
|
||||
<div class="hidden peer-checked:flex items-center gap-2 mt-3 mb-6">
|
||||
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday %></div>
|
||||
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday, min: Account::Entry.min_supported_date %></div>
|
||||
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance") %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<div class="space-y-4">
|
||||
<%= turbo_stream_from @account %>
|
||||
|
||||
<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= image_tag account_logo_url(@account), class: "w-8 h-8" %>
|
||||
@@ -50,14 +52,19 @@
|
||||
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
||||
<div class="p-4 flex justify-between">
|
||||
<div class="space-y-2">
|
||||
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
|
||||
<%= tag.p format_money(@account.balance_money, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
|
||||
<div class="flex items-center gap-1">
|
||||
<div>
|
||||
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
|
||||
</div>
|
||||
<%= render "tooltip", account: @account if @account.investment? %>
|
||||
</div>
|
||||
<%= tag.p format_money(@account.value, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
|
||||
<div>
|
||||
<% if @balance_series.trend.direction.flat? %>
|
||||
<% if @series.trend.direction.flat? %>
|
||||
<%= tag.span t(".no_change"), class: "text-gray-500" %>
|
||||
<% else %>
|
||||
<%= tag.span format_money(@balance_series.trend.value), style: "color: #{@balance_series.trend.color}" %>
|
||||
<%= tag.span "(#{@balance_series.trend.percent}%)", style: "color: #{@balance_series.trend.color}" %>
|
||||
<%= tag.span format_money(@series.trend.value), style: "color: #{@trend.color}" %>
|
||||
<%= tag.span "(#{@trend.percent}%)", style: "color: #{@trend.color}" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span period_label(@period), class: "text-gray-500" %>
|
||||
@@ -68,26 +75,21 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %>
|
||||
<%= render partial: "shared/line_chart", locals: { series: @series } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% selected_tab = params[:tab] || "value" %>
|
||||
<% selected_tab_key, selected_tab_content_path = selected_account_tab(@account).values_at(:key, :content_path) %>
|
||||
|
||||
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
|
||||
<%= link_to t(".value"), account_path(tab: "value"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "value"] %>
|
||||
<%= link_to t(".transactions"), account_path(tab: "transactions"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "transactions"] %>
|
||||
<% account_tabs(@account).each do |tab| %>
|
||||
<%= link_to tab[:label], tab[:path], class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab_key == tab[:key]] %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[800px]">
|
||||
<% if selected_tab == "transactions" %>
|
||||
<%= turbo_frame_tag dom_id(@account, "transactions"), src: transaction_account_entries_path(@account) do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= turbo_frame_tag dom_id(@account, "valuations"), src: valuation_account_entries_path(@account) do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% end %>
|
||||
<%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_content_path do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= render "form", category: @category %>
|
||||
</article>
|
||||
<%= modal_form_wrapper title: t(".edit") do %>
|
||||
<%= render "form", category: @category %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".new_category") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= render "form", category: @category %>
|
||||
</article>
|
||||
<%= modal_form_wrapper title: t(".new_category") do %>
|
||||
<%= render "form", category: @category %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto p-4 w-screen max-w-md">
|
||||
<div class="space-y-2">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".delete_category") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
<%= modal_form_wrapper title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name) do %>
|
||||
<%= styled_form_with url: category_deletions_path(@category),
|
||||
class: "space-y-4",
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_category_id,
|
||||
Current.family.categories.alphabetically.without(@category),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<p class="text-gray-500 font-light">
|
||||
<%= t(".explanation", category_name: @category.name) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with url: category_deletions_path(@category),
|
||||
class: "space-y-4",
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_category_id,
|
||||
Current.family.categories.alphabetically.without(@category),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
</article>
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<% is_selected = category.id === @selected_category&.id %>
|
||||
|
||||
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||
<%= button_to account_entry_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<% end %>
|
||||
|
||||
<% if @transaction.category %>
|
||||
<%= button_to account_entry_path(@transaction.entry.account, @transaction.entry),
|
||||
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry),
|
||||
method: :patch,
|
||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user