diff --git a/app/controllers/account/entries_controller.rb b/app/controllers/account/entries_controller.rb index b12ae099..22bbe34f 100644 --- a/app/controllers/account/entries_controller.rb +++ b/app/controllers/account/entries_controller.rb @@ -12,6 +12,10 @@ class Account::EntriesController < ApplicationController @valuation_entries = @account.entries.account_valuations.reverse_chronological end + def trades + @trades = @account.entries.account_trades.reverse_chronological + end + def new @entry = @account.entries.build.tap do |entry| if params[:entryable_type] diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb new file mode 100644 index 00000000..136040ae --- /dev/null +++ b/app/controllers/account/holdings_controller.rb @@ -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 diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 17634b89..86b07a80 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -33,7 +33,7 @@ module Account::EntriesHelper 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 diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 54ec09fa..6817d7fb 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -23,18 +23,37 @@ 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) } + value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) } + transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) } + trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) } + + return [ holdings_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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 141db192..b834f54b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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| diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index ed989aee..5a713f0c 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -59,6 +59,7 @@ export default class extends Controller { deselectAll() { this.selectedIdsValue = [] + this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false) } selectedIdsValueChanged() { diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index b5a63248..775a3b7c 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -1,6 +1,46 @@ 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 :for, ->(security) { where(security_id: security).order(:date) } + + delegate :name, to: :security + delegate :symbol, to: :security + + def weight + return nil unless amount + + portfolio_value = account.holdings.current.where.not(amount: nil).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 diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb index 3f2af7a7..1494c8e0 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/account/holding/syncer.rb @@ -38,14 +38,18 @@ class Account::Holding::Syncer @portfolio = generate_next_portfolio(@portfolio, trades) @portfolio.map do |isin, holding| - price = Security::Price.find_by!(date: date, isin: isin).price + trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] } + trade_price = trade&.account_trade&.price + + price = Security::Price.find_by(date: date, isin: isin)&.price || 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 @@ -61,6 +65,7 @@ class Account::Holding::Syncer qty: new_qty, price: price, amount: new_qty * price, + currency: entry.currency, security_id: trade.security_id } end @@ -85,6 +90,7 @@ class Account::Holding::Syncer qty: holding.qty, price: holding.price, amount: holding.amount, + currency: holding.currency, security_id: holding.security_id } end diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb index 1442ec0b..721399b3 100644 --- a/app/models/account/sync.rb +++ b/app/models/account/sync.rb @@ -78,6 +78,6 @@ class Account::Sync < ApplicationRecord partial: "shared/notification", locals: { type: type, message: message } ) - broadcast_refresh_to account + account.family.broadcast_refresh end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 2fb27157..b1ea2f12 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -165,6 +165,9 @@ class Demo::Generator end def load_securities! + # Create an unknown security to simulate edge cases + Security.create! isin: "unknown", symbol: "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 }, @@ -200,6 +203,10 @@ class Demo::Generator aapl = Security.find_by(symbol: "AAPL") tm = Security.find_by(symbol: "TM") msft = Security.find_by(symbol: "MSFT") + unknown = Security.find_by(symbol: "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) trades = [ { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, @@ -212,7 +219,7 @@ 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(isin: security.isin, date: date)&.price || 1 name_prefix = qty < 0 ? "Sell " : "Buy " account.entries.create! \ diff --git a/app/models/time_series/trend.rb b/app/models/time_series/trend.rb index 88fdd3a1..4b989e9f 100644 --- a/app/models/time_series/trend.rb +++ b/app/models/time_series/trend.rb @@ -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 diff --git a/app/views/account/entries/entryables/trade/_selection_bar.html.erb b/app/views/account/entries/entryables/trade/_selection_bar.html.erb new file mode 100644 index 00000000..f4f7208e --- /dev/null +++ b/app/views/account/entries/entryables/trade/_selection_bar.html.erb @@ -0,0 +1,15 @@ +
+
+ <%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %> + +

+
+ +
+ <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + + <% end %> +
+
diff --git a/app/views/account/entries/entryables/trade/_show.html.erb b/app/views/account/entries/entryables/trade/_show.html.erb new file mode 100644 index 00000000..ccd64727 --- /dev/null +++ b/app/views/account/entries/entryables/trade/_show.html.erb @@ -0,0 +1,31 @@ +<%# locals: (entry:) %> + +<% trade, account = entry.account_trade, entry.account %> + +<%= drawer do %> +
+
+
+

+ <%= format_money -entry.amount_money %> + <%= entry.currency %> +

+
+ + <%= entry.date.strftime("%A %d %B") %> +
+ +
+
+ +

<%= t(".overview") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+

Details coming soon...

+
+
+
+
+<% end %> diff --git a/app/views/account/entries/entryables/trade/_trade.html.erb b/app/views/account/entries/entryables/trade/_trade.html.erb new file mode 100644 index 00000000..5c01d83c --- /dev/null +++ b/app/views/account/entries/entryables/trade/_trade.html.erb @@ -0,0 +1,41 @@ +<%# locals: (entry:, selectable: true, **opts) %> + +<% trade, account = entry.account_trade, entry.account %> + +
+
+ <% 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 %> + +
+ <%= tag.div class: ["flex items-center gap-2"] do %> +
+ <%= entry.name[0].upcase %> +
+ +
+ <% if entry.new_record? %> + <%= content_tag :p, entry.name %> + <% else %> + <%= link_to entry.name, + account_entry_path(account, entry), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "hover:underline hover:text-gray-800" %> + <% end %> +
+ <% end %> +
+
+ +
+ <%= tag.p trade.buy? ? t(".buy") : t(".sell") %> +
+ +
+ <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %> +
+ +
diff --git a/app/views/account/entries/_selection_bar.html.erb b/app/views/account/entries/entryables/transaction/_selection_bar.html.erb similarity index 100% rename from app/views/account/entries/_selection_bar.html.erb rename to app/views/account/entries/entryables/transaction/_selection_bar.html.erb diff --git a/app/views/account/entries/trades.html.erb b/app/views/account/entries/trades.html.erb new file mode 100644 index 00000000..2c02dd91 --- /dev/null +++ b/app/views/account/entries/trades.html.erb @@ -0,0 +1,42 @@ +<%= turbo_frame_tag dom_id(@account, "trades") do %> +
" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs"> +
+

<%= t(".trades") %>

+ <%= link_to new_account_entry_path(@account), + disabled: true, + class: "cursor-not-allowed 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") %> + <%= t(".new") %> + <% end %> +
+ +
+
+ <%= check_box_tag "selection_entry", + class: "maybe-checkbox maybe-checkbox--light", + data: { action: "bulk-select#togglePageSelection" } %> + <%= tag.p t(".trade") %> +
+ + <%= tag.p t(".type"), class: "col-span-3 justify-self-end" %> + <%= tag.p t(".amount"), class: "col-span-3 justify-self-end" %> +
+ +
+ + + <% if @trades.empty? %> +

<%= t(".no_trades") %>

+ <% else %> +
+ <% @trades.group_by(&:date).each do |date, entries| %> + <%= render "entry_group", date:, entries: entries %> + <% end %> +
+ <% end %> +
+
+<% end %> diff --git a/app/views/account/entries/transactions.html.erb b/app/views/account/entries/transactions.html.erb index ea824d3e..623ad9c0 100644 --- a/app/views/account/entries/transactions.html.erb +++ b/app/views/account/entries/transactions.html.erb @@ -12,7 +12,7 @@
"> <% if @transaction_entries.empty? %> diff --git a/app/views/account/holdings/_holding.html.erb b/app/views/account/holdings/_holding.html.erb new file mode 100644 index 00000000..62878810 --- /dev/null +++ b/app/views/account/holdings/_holding.html.erb @@ -0,0 +1,45 @@ +<%# locals: (holding:) %> + +<%= turbo_frame_tag dom_id(holding) do %> +
+
+ <%= render "shared/circle_logo", name: holding.name %> +
+ <%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %> + <%= tag.p holding.symbol, class: "text-gray-500 text-xs uppercase" %> +
+
+ +
+ <% 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 %> +
+ +
+ <%= tag.p format_money holding.avg_cost %> + <%= tag.p t(".per_share"), class: "font-normal text-gray-500" %> +
+ +
+ <% 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" %> +
+ +
+ <% 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 %> +
+
+<% end %> diff --git a/app/views/account/holdings/_ruler.html.erb b/app/views/account/holdings/_ruler.html.erb new file mode 100644 index 00000000..31c6ee6c --- /dev/null +++ b/app/views/account/holdings/_ruler.html.erb @@ -0,0 +1 @@ +
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb new file mode 100644 index 00000000..e58a0626 --- /dev/null +++ b/app/views/account/holdings/index.html.erb @@ -0,0 +1,37 @@ +<%= turbo_frame_tag dom_id(@account, "holdings") do %> +
+
+ <%= tag.h2 t(".holdings"), class: "font-medium text-lg" %> + <%= link_to new_account_holding_path(@account), + disabled: true, + data: { turbo_frame: :modal }, + class: "cursor-not-allowed 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 %> +
+ +
+
+ <%= 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" %> +
+ +
+ <% if @holdings.any? %> + <%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %> + <% elsif @account.needs_sync? || true %> +
+

<%= t(".needs_sync") %>

+ <%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %> +
+ <% else %> +

<%= t(".no_holdings") %>

+ <% end %> +
+
+
+<% end %> diff --git a/app/views/account/holdings/new.html.erb b/app/views/account/holdings/new.html.erb new file mode 100644 index 00000000..91dd849b --- /dev/null +++ b/app/views/account/holdings/new.html.erb @@ -0,0 +1 @@ +

Coming soon...

diff --git a/app/views/account/holdings/show.html.erb b/app/views/account/holdings/show.html.erb new file mode 100644 index 00000000..07ae6798 --- /dev/null +++ b/app/views/account/holdings/show.html.erb @@ -0,0 +1,45 @@ +<%= drawer do %> +
+
+
+ <%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %> + <%= tag.p @holding.symbol.upcase, class: "text-sm text-gray-500" %> +
+ + <%= render "shared/circle_logo", name: @holding.name %> +
+ +
+ +

<%= t(".overview") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+

Coming soon...

+
+
+ +
+ +

<%= t(".history") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+

Coming soon...

+
+
+ +
+ +

<%= t(".settings") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+

Coming soon...

+
+
+
+<% end %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index b67c5869..478a6ff3 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -1,6 +1,6 @@ <%= turbo_stream_from @account %> -
+<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
<%= image_tag account_logo_url(@account), class: "w-8 h-8" %> @@ -74,22 +74,17 @@
- <% selected_tab = params[:tab] || "value" %> + <% selected_tab_key, selected_tab_content_path = selected_account_tab(@account).values_at(:key, :content_path) %>
- <%= 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 %>
- <% 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 %>
-
+<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ab57b5d3..6f4851c8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -32,6 +32,7 @@
<%= family_notifications_stream %> + <%= family_stream %> <%= content_for?(:content) ? yield(:content) : yield %> diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb index 4bce63fc..ebe70cf6 100644 --- a/app/views/shared/_drawer.html.erb +++ b/app/views/shared/_drawer.html.erb @@ -1,8 +1,8 @@ <%= turbo_frame_tag "drawer" do %>
-
-
+
+
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index b33c5bc4..4d37607d 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -11,7 +11,7 @@ <% if @transaction_entries.present? %>
diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index da29b2e4..8d81a30e 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -10,7 +10,18 @@ en: description: Try adding an entry, editing filters or refining your search title: No entries found entryables: + trade: + show: + overview: Overview + trade: + buy: Buy + sell: Sell transaction: + selection_bar: + mark_transfers: Mark as transfers? + mark_transfers_confirm: Mark as transfers + mark_transfers_message: By marking transactions as transfers, they will + no longer be included in income or spending calculations. show: account_label: Account account_placeholder: Select an account @@ -57,11 +68,13 @@ en: value_update: Value update loading: loading: Loading entries... - selection_bar: - mark_transfers: Mark as transfers? - mark_transfers_confirm: Mark as transfers - mark_transfers_message: By marking transactions as transfers, they will no - longer be included in income or spending calculations. + trades: + amount: Amount + new: New trade + no_trades: No trades for this account yet. + trade: trade + trades: Trades + type: Type transactions: new: New transaction no_transactions: No transactions for this account yet. diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml new file mode 100644 index 00000000..6c931874 --- /dev/null +++ b/config/locales/views/account/holdings/en.yml @@ -0,0 +1,21 @@ +--- +en: + account: + holdings: + holding: + per_share: per share + shares: "%{qty} shares" + index: + cost: cost + holdings: Holdings + name: name + needs_sync: Your account needs to sync the latest prices to calculate this + portfolio + new_holding: New holding + no_holdings: No holdings to show. + return: total return + weight: weight + show: + history: History + overview: Overview + settings: Settings diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index b9f425fd..de32cef1 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -56,12 +56,14 @@ en: information because you'll need to add it as a new account.

" confirm_title: Delete account? edit: Edit + holdings: Holdings import: Import transactions no_change: No change sync_message_missing_rates: Since exchange rates haven't been synced, balance graphs may not reflect accurate values. sync_message_unknown_error: An error has occurred during the sync. total_value: Total Value + trades: Trades transactions: Transactions value: Value summary: diff --git a/config/routes.rb b/config/routes.rb index 093ac809..8c3a73f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,10 +78,13 @@ Rails.application.routes.draw do scope module: :account do resource :logo, only: :show + resources :holdings, only: %i[ index new show ] + resources :entries, except: :index do collection do get "transactions", as: :transaction get "valuations", as: :valuation + get "trades", as: :trade end end end diff --git a/test/controllers/account/holdings_controller_test.rb b/test/controllers/account/holdings_controller_test.rb new file mode 100644 index 00000000..c1b8bb2e --- /dev/null +++ b/test/controllers/account/holdings_controller_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + @account = accounts(:investment) + @holding = @account.holdings.current.first + end + + test "gets holdings" do + get account_holdings_url(@account) + assert_response :success + end + + test "gets holding" do + get account_holding_path(@account, @holding) + + assert_response :success + end +end diff --git a/test/fixtures/account/holdings.yml b/test/fixtures/account/holdings.yml index fabc6453..661cff9a 100644 --- a/test/fixtures/account/holdings.yml +++ b/test/fixtures/account/holdings.yml @@ -3,6 +3,7 @@ one: security: aapl date: <%= Date.current %> qty: 10 + price: 215 amount: 2150 # 10 * $215 currency: USD @@ -11,5 +12,6 @@ two: security: aapl date: <%= 1.day.ago.to_date %> qty: 10 + price: 214 amount: 2140 # 10 * $214 currency: USD diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb index 4f548168..6f01aabc 100644 --- a/test/models/account/balance/syncer_test.rb +++ b/test/models/account/balance/syncer_test.rb @@ -37,11 +37,11 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase test "syncs account with trades only" do aapl = securities(:aapl) - create_trade(account: @investment_account, date: 1.day.ago.to_date, security: aapl, qty: 10, price: 200) + create_trade(aapl, account: @investment_account, date: 1.day.ago.to_date, qty: 10) run_sync_for @investment_account - assert_equal [ 52000, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance) + assert_equal [ 52140, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance) end test "syncs account with valuations and transactions" do diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb index 8a84295b..14961b9b 100644 --- a/test/models/account/holding/syncer_test.rb +++ b/test/models/account/holding/syncer_test.rb @@ -1,7 +1,7 @@ require "test_helper" class Account::Holding::SyncerTest < ActiveSupport::TestCase - include Account::EntriesTestHelper + include Account::EntriesTestHelper, SecuritiesTestHelper setup do @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) @@ -25,12 +25,12 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase { date: Date.current, price: 124 } ]) - create_trade(security1, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN + create_trade(security1, account: @account, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN - create_trade(security1, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN - create_trade(security2, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA + create_trade(security1, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN + create_trade(security2, account: @account, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA - create_trade(security1, qty: -10, date: Date.current) # sell 10 shares of AMZN + create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN expected = [ { symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date }, @@ -45,6 +45,27 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase assert_holdings(expected) end + test "generates all holdings even when missing security prices" do + aapl = create_security("AMZN", prices: [ + { date: 1.day.ago.to_date, price: 215 } + ]) + + create_trade(aapl, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210) + + # 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price + # 1 day ago — finds daily price, uses it + # Today — no daily price, no entry, so price and amount are `nil` + expected = [ + { symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date }, + { symbol: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date }, + { symbol: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current } + ] + + run_sync_for(@account) + + assert_holdings(expected) + end + private def assert_holdings(expected_holdings) @@ -64,37 +85,6 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase end end - def create_security(symbol, prices:) - isin_codes = { - "AMZN" => "US0231351067", - "NVDA" => "US67066G1040" - } - - isin = isin_codes[symbol] - - prices.each do |price| - Security::Price.create! isin: isin, date: price[:date], price: price[:price] - end - - Security.create! isin: isin, symbol: symbol - end - - def create_trade(security, qty:, date:) - price = Security::Price.find_by!(isin: security.isin, date: date).price - - trade = Account::Trade.new \ - qty: qty, - security: security, - price: price - - @account.entries.create! \ - name: "Trade", - date: date, - amount: qty * price, - currency: "USD", - entryable: trade - end - def run_sync_for(account) Account::Holding::Syncer.new(account).run end diff --git a/test/models/account/holding_test.rb b/test/models/account/holding_test.rb index ebc93ee6..291fb762 100644 --- a/test/models/account/holding_test.rb +++ b/test/models/account/holding_test.rb @@ -1,7 +1,71 @@ require "test_helper" +require "ostruct" class Account::HoldingTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + include Account::EntriesTestHelper, SecuritiesTestHelper + + setup do + @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) + + # Current day holding instances + @amzn, @nvda = load_holdings + end + + test "calculates portfolio weight" do + expected_portfolio_value = 6960.0 + expected_amzn_weight = 3240.0 / expected_portfolio_value * 100 + expected_nvda_weight = 3720.0 / expected_portfolio_value * 100 + + assert_in_delta expected_amzn_weight, @amzn.weight, 0.001 + assert_in_delta expected_nvda_weight, @nvda.weight, 0.001 + end + + test "calculates simple average cost basis" do + assert_equal Money.new((212.0 + 216.0) / 2), @amzn.avg_cost + assert_equal Money.new((128.0 + 124.0) / 2), @nvda.avg_cost + end + + test "calculates total return trend" do + # Gained $30, or 0.93% + assert_equal Money.new(30), @amzn.trend.value + assert_in_delta 0.9, @amzn.trend.percent, 0.001 + + # Lost $60, or -1.59% + assert_equal Money.new(-60), @nvda.trend.value + assert_in_delta -1.6, @nvda.trend.percent, 0.001 + end + + private + + def load_holdings + security1 = create_security("AMZN", prices: [ + { date: 1.day.ago.to_date, price: 212.00 }, + { date: Date.current, price: 216.00 } + ]) + + security2 = create_security("NVDA", prices: [ + { date: 1.day.ago.to_date, price: 128.00 }, + { date: Date.current, price: 124.00 } + ]) + + create_holding(security1, 1.day.ago.to_date, 10) + amzn = create_holding(security1, Date.current, 15) + + create_holding(security2, 1.day.ago.to_date, 5) + nvda = create_holding(security2, Date.current, 30) + + [ amzn, nvda ] + end + + def create_holding(security, date, qty) + price = Security::Price.find_by(date: date, isin: security.isin).price + + @account.holdings.create! \ + date: date, + security: security, + qty: qty, + price: price, + amount: qty * price, + currency: "USD" + end end diff --git a/test/support/account/entries_test_helper.rb b/test/support/account/entries_test_helper.rb index 6afc40b1..926c0fd7 100644 --- a/test/support/account/entries_test_helper.rb +++ b/test/support/account/entries_test_helper.rb @@ -28,12 +28,19 @@ module Account::EntriesTestHelper Account::Entry.create! entry_defaults.merge(attributes) end - def create_trade(account:, security:, qty:, price:, date:) + def create_trade(security, account:, qty:, date:, price: nil) + trade_price = price || Security::Price.find_by!(isin: security.isin, date: date).price + + trade = Account::Trade.new \ + qty: qty, + security: security, + price: trade_price + account.entries.create! \ - date: date, - amount: qty * price, - currency: "USD", name: "Trade", - entryable: Account::Trade.new(qty: qty, price: price, security: security) + date: date, + amount: qty * trade_price, + currency: "USD", + entryable: trade end end diff --git a/test/support/securities_test_helper.rb b/test/support/securities_test_helper.rb new file mode 100644 index 00000000..bf823e86 --- /dev/null +++ b/test/support/securities_test_helper.rb @@ -0,0 +1,16 @@ +module SecuritiesTestHelper + def create_security(symbol, prices:) + isin_codes = { + "AMZN" => "US0231351067", + "NVDA" => "US67066G1040" + } + + isin = isin_codes[symbol] + + prices.each do |price| + Security::Price.create! isin: isin, date: price[:date], price: price[:price] + end + + Security.create! isin: isin, symbol: symbol + end +end