Compare commits
17 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc7e1c30c | ||
|
|
76dd5e57fb | ||
|
|
701e17829d | ||
|
|
7c2091b343 | ||
|
|
ef4be7948a | ||
|
|
c8590d53ba | ||
|
|
f62c5e43c3 | ||
|
|
82568b4d8c | ||
|
|
9d006409c2 | ||
|
|
55a085f01f | ||
|
|
23dcdf6e26 | ||
|
|
05e3e689b5 | ||
|
|
01f50dc54c | ||
|
|
5d213f2e6a | ||
|
|
952d847c15 | ||
|
|
e7dc6b88ea | ||
|
|
75ded1c18f |
36
Gemfile.lock
36
Gemfile.lock
@@ -7,7 +7,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 8035bece705f60e6bddca70ee7d88e935a242bf8
|
||||
revision: 1b8903346000e1848e62e09429d325499af03b3f
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.beta3)
|
||||
@@ -178,7 +178,7 @@ GEM
|
||||
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-net_http (>= 2.0, < 3.2)
|
||||
@@ -193,7 +193,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.0.3)
|
||||
good_job (4.1.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -257,7 +257,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.24.1)
|
||||
mocha (2.4.0)
|
||||
mocha (2.4.2)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
@@ -287,7 +287,7 @@ GEM
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (8.6.3)
|
||||
pagy (9.0.2)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.1.0)
|
||||
ast (~> 2.4.1)
|
||||
@@ -340,7 +340,7 @@ GEM
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.0)
|
||||
rexml (3.3.2)
|
||||
strscan
|
||||
rubocop (1.63.5)
|
||||
json (~> 2.3)
|
||||
@@ -371,12 +371,12 @@ 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)
|
||||
@@ -386,7 +386,7 @@ GEM
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.22.0)
|
||||
selenium-webdriver (4.23.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -405,29 +405,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.3)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
tailwindcss-rails (2.6.3-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
tailwindcss-rails (2.6.3-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
tailwindcss-rails (2.6.3-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
tailwindcss-rails (2.6.3-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
tailwindcss-rails (2.6.3-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,7 +447,7 @@ 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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -4,7 +4,7 @@ class TransactionsController < ApplicationController
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -169,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 },
|
||||
@@ -204,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 },
|
||||
@@ -216,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! \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
31
app/views/account/entries/entryables/trade/_show.html.erb
Normal file
31
app/views/account/entries/entryables/trade/_show.html.erb
Normal file
@@ -0,0 +1,31 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<% trade, account = entry.account_trade, entry.account %>
|
||||
|
||||
<%= 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 %>
|
||||
41
app/views/account/entries/entryables/trade/_trade.html.erb
Normal file
41
app/views/account/entries/entryables/trade/_trade.html.erb
Normal file
@@ -0,0 +1,41 @@
|
||||
<%# 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[0].upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-1 col-span-3">
|
||||
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex items-center justify-end">
|
||||
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@
|
||||
<%= 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 %>
|
||||
|
||||
42
app/views/account/entries/trades.html.erb
Normal file
42
app/views/account/entries/trades.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_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") %>
|
||||
<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 "account/entries/entryables/trade/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @trades.empty? %>
|
||||
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<% @trades.group_by(&:date).each do |date, entries| %>
|
||||
<%= render "entry_group", date:, entries: entries %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
|
||||
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
||||
<%= render "selection_bar" %>
|
||||
<%= render "account/entries/entryables/transaction/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @transaction_entries.empty? %>
|
||||
|
||||
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 %>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</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_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 %>
|
||||
</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.symbol.upcase, 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 %>
|
||||
@@ -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" %>
|
||||
@@ -72,22 +74,17 @@
|
||||
</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 %>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</div>
|
||||
|
||||
<%= family_notifications_stream %>
|
||||
<%= family_stream %>
|
||||
|
||||
<%= content_for?(:content) ? yield(:content) : yield %>
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<div
|
||||
data-controller="pie-chart"
|
||||
class="w-full aspect-1"
|
||||
data-pie-chart-label-value="Total Assets"
|
||||
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:assets]) %>">
|
||||
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:assets]) %>"
|
||||
data-pie-chart-total-value="<%= format_money(account_groups[:assets].sum, precision: 0) %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,8 +20,8 @@
|
||||
<div
|
||||
data-controller="pie-chart"
|
||||
class="w-full aspect-1"
|
||||
data-pie-chart-label-value="Total Debts"
|
||||
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:liabilities]) %>">
|
||||
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:liabilities]) %>"
|
||||
data-pie-chart-total-value="<%= format_money(account_groups[:liabilities].sum, precision: 0) %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%= turbo_frame_tag "drawer" do %>
|
||||
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<div class="flex justify-end items-center h-9">
|
||||
<div data-action="click->modal#close" class="cursor-pointer">
|
||||
<div class="flex justify-end items-center pb-4">
|
||||
<div data-action="click->modal#close" class="cursor-pointer p-2">
|
||||
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
||||
<% end %>
|
||||
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
|
||||
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.today %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
|
||||
<%= f.select :per_page,
|
||||
options_for_select(["10", "20", "30", "50"], pagy.items),
|
||||
options_for_select(["10", "20", "30", "50"], pagy.limit),
|
||||
{},
|
||||
class: "py-1.5 pr-8 text-sm text-gray-900 font-medium border border-gray-200 rounded-lg focus:border-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900",
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<% if @transaction_entries.present? %>
|
||||
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
|
||||
<%= render "account/entries/selection_bar" %>
|
||||
<%= render "account/entries/entryables/transaction/selection_bar" %>
|
||||
</div>
|
||||
<div class="grow overflow-y-auto">
|
||||
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.11"
|
||||
"0.1.0-alpha.12"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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.
|
||||
|
||||
21
config/locales/views/account/holdings/en.yml
Normal file
21
config/locales/views/account/holdings/en.yml
Normal file
@@ -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
|
||||
@@ -56,12 +56,14 @@ en:
|
||||
information because you'll need to add it as a new account.</p>"
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLastSyncedAtToFamily < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :families, :last_synced_at, :datetime
|
||||
end
|
||||
end
|
||||
5
db/schema.rb
generated
5
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -118,7 +118,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
|
||||
t.boolean "is_active", default: true, null: false
|
||||
t.date "last_sync_date"
|
||||
t.uuid "institution_id"
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||
t.index ["institution_id"], name: "index_accounts_on_institution_id"
|
||||
@@ -194,6 +194,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "currency", default: "USD"
|
||||
t.datetime "last_synced_at"
|
||||
end
|
||||
|
||||
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
||||
@@ -91,7 +91,8 @@ class Money
|
||||
unit: currency.symbol,
|
||||
precision: currency.default_precision,
|
||||
delimiter: currency.delimiter,
|
||||
separator: currency.separator
|
||||
separator: currency.separator,
|
||||
format: currency.default_format
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
20
test/controllers/account/holdings_controller_test.rb
Normal file
20
test/controllers/account/holdings_controller_test.rb
Normal file
@@ -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
|
||||
@@ -24,6 +24,14 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "sets last_login_at on successful registration" do
|
||||
post registration_url, params: { user: {
|
||||
email: "john@example.com",
|
||||
password: "password",
|
||||
password_confirmation: "password" } }
|
||||
assert_not_nil User.find_by(email: "john@example.com").last_login_at
|
||||
end
|
||||
|
||||
test "create when hosted requires an invite code" do
|
||||
with_env_overrides REQUIRE_INVITE_CODE: "true" do
|
||||
assert_no_difference "User.count" do
|
||||
|
||||
18
test/controllers/sessions_controller_test.rb
Normal file
18
test/controllers/sessions_controller_test.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
require "test_helper"
|
||||
|
||||
class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
end
|
||||
|
||||
test "can sign in" do
|
||||
post session_url, params: { email: @user.email, password: "password" }
|
||||
assert_redirected_to root_url
|
||||
end
|
||||
|
||||
test "sets last_login_at on successful login" do
|
||||
assert_changes -> { @user.reload.last_login_at }, from: nil do
|
||||
post session_url, params: { email: @user.email, password: "password" }
|
||||
end
|
||||
end
|
||||
end
|
||||
2
test/fixtures/account/holdings.yml
vendored
2
test/fixtures/account/holdings.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,12 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||
@entry = account_entries :transaction
|
||||
end
|
||||
|
||||
test "entry cannot be older than 10 years ago" do
|
||||
assert_raises ActiveRecord::RecordInvalid do
|
||||
@entry.update! date: 50.years.ago.to_date
|
||||
end
|
||||
end
|
||||
|
||||
test "valuations cannot have more than one entry per day" do
|
||||
existing_valuation = account_entries :valuation
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,10 @@ class AccountTest < ActiveSupport::TestCase
|
||||
@account.sync start_date: start_date
|
||||
end
|
||||
|
||||
test "needs sync if account has not synced today" do
|
||||
assert @account.needs_sync?
|
||||
end
|
||||
|
||||
test "groups accounts by type" do
|
||||
result = @family.accounts.by_group(period: Period.all)
|
||||
assets = result[:assets]
|
||||
|
||||
@@ -48,6 +48,14 @@ class FamilyTest < ActiveSupport::TestCase
|
||||
assert_equal Money.new(50000, @family.currency), @family.net_worth
|
||||
end
|
||||
|
||||
test "needs sync if last family sync was before today" do
|
||||
assert @family.needs_sync?
|
||||
|
||||
@family.update! last_synced_at: Time.now
|
||||
|
||||
assert_not @family.needs_sync?
|
||||
end
|
||||
|
||||
test "syncs active accounts" do
|
||||
account = create_account(balance: 1000, accountable: CreditCard.new, is_active: false)
|
||||
|
||||
@@ -57,7 +65,9 @@ class FamilyTest < ActiveSupport::TestCase
|
||||
|
||||
account.update! is_active: true
|
||||
|
||||
Account.any_instance.expects(:sync_later).with(start_date: nil).once
|
||||
Account.any_instance.expects(:needs_sync?).once.returns(true)
|
||||
Account.any_instance.expects(:last_sync_date).once.returns(2.days.ago.to_date)
|
||||
Account.any_instance.expects(:sync_later).with(start_date: 2.days.ago.to_date).once
|
||||
|
||||
@family.sync
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
16
test/support/securities_test_helper.rb
Normal file
16
test/support/securities_test_helper.rb
Normal file
@@ -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
|
||||
@@ -60,7 +60,7 @@ class AccountsTest < ApplicationSystemTestCase
|
||||
select "Chase", from: "Financial institution"
|
||||
fill_in "account[balance]", with: 100.99
|
||||
check "Add a start balance for this account"
|
||||
fill_in "Start date (optional)", with: 10.days.ago.to_date.to_s
|
||||
fill_in "Start date (optional)", with: 10.days.ago.to_date
|
||||
fill_in "Start balance (optional)", with: 95
|
||||
click_button "Add #{humanized_accountable(accountable_type).downcase}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user