1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 21:45:23 +02:00

Improve account transaction, trade, and valuation editing and sync experience (#1506)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Consolidate entry controller logic

* Transaction builder

* Update trades controller to use new params

* Load account charts in turbo frames, fix PG overflow

* Consolidate tests

* Tests passing

* Remove unused code

* Add client side trade form validations
This commit is contained in:
Zach Gollwitzer 2024-11-27 16:01:50 -05:00 committed by GitHub
parent 76f2714006
commit c3248cd796
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 1103 additions and 1159 deletions

View file

@ -12,7 +12,7 @@ class Account < ApplicationRecord
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
has_many :balances, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy

View file

@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord
}
def sync_account_later
if destroyed?
sync_start_date = previous_entry&.date
sync_start_date = if destroyed?
previous_entry&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
[ date_previously_was, date ].compact.min
end
account.sync_later(start_date: sync_start_date)

View file

@ -1,46 +0,0 @@
class Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_builder.save
end
end
private
def create_builder
case type
when "buy", "sell"
create_trade_builder
else
create_transaction_builder
end
end
def create_trade_builder
Account::TradeBuilder.new \
type: type,
date: date,
qty: qty,
ticker: ticker,
price: price,
account: account
end
def create_transaction_builder
Account::TransactionBuilder.new \
type: type,
date: date,
amount: amount,
account: account,
currency: currency,
transfer_account_id: transfer_account_id
end
end

View file

@ -28,8 +28,7 @@ class Account::Trade < ApplicationRecord
def name
prefix = sell? ? "Sell " : "Buy "
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
entry.name || generated
prefix + "#{qty.abs} shares of #{security.ticker}"
end
def unrealized_gain_loss

View file

@ -1,33 +1,103 @@
class Account::TradeBuilder < Account::EntryBuilder
class Account::TradeBuilder
include ActiveModel::Model
TYPES = %w[buy sell].freeze
attr_accessor :type, :qty, :price, :ticker, :date, :account
validates :type, :qty, :price, :ticker, :date, presence: true
validates :price, numericality: { greater_than: 0 }
validates :type, inclusion: { in: TYPES }
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :type, :transfer_account_id
def save
if valid?
create_entry
end
buildable.save
end
def errors
buildable.errors
end
def sync_account_later
buildable.sync_account_later
end
private
def buildable
case type
when "buy", "sell"
build_trade
when "deposit", "withdrawal"
build_transfer
when "interest"
build_interest
else
raise "Unknown trade type: #{type}"
end
end
def create_entry
account.entries.account_trades.create! \
def build_trade
account.entries.new(
date: date,
amount: amount,
currency: account.currency,
amount: signed_amount,
currency: currency,
entryable: Account::Trade.new(
security: security,
qty: signed_qty,
price: price.to_d,
currency: account.currency
price: price,
currency: currency,
security: security
)
)
end
def build_transfer
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
if transfer_account
from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account
Account::Transfer.build_from_accounts(
from_account,
to_account,
date: date,
amount: signed_amount
)
else
account.entries.build(
name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
marked_as_transfer: true,
entryable: Account::Transaction.new
)
end
end
def build_interest
account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Account::Transaction.new
)
end
def signed_qty
return nil unless type.in?([ "buy", "sell" ])
type == "sell" ? -qty.to_d : qty.to_d
end
def signed_amount
case type
when "buy", "sell"
signed_qty * price.to_d
when "deposit", "withdrawal"
type == "deposit" ? -amount.to_d : amount.to_d
when "interest"
amount.to_d * -1
end
end
def family
account.family
end
def security
@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder
security
end
def amount
price.to_d * signed_qty
end
def signed_qty
_qty = qty.to_d
_qty = _qty * -1 if type == "sell"
_qty
end
end

View file

@ -1,64 +0,0 @@
class Account::TransactionBuilder
include ActiveModel::Model
TYPES = %w[income expense interest transfer_in transfer_out].freeze
attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id
validates :type, :amount, :date, presence: true
validates :type, inclusion: { in: TYPES }
def save
if valid?
transfer? ? create_transfer : create_transaction
end
end
private
def transfer?
%w[transfer_in transfer_out].include?(type)
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
Account::Transfer.create! entries: [ outflow, inflow ]
inflow
end
def create_unlinked_transfer(account_id, amount)
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
end
def create_transaction
build_entry(account.id, signed_amount).tap(&:save!)
end
def build_entry(account_id, amount, marked_as_transfer: false)
Account::Entry.new \
account_id: account_id,
name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest",
amount: amount,
currency: currency,
date: date,
marked_as_transfer: marked_as_transfer,
entryable: Account::Transaction.new
end
def signed_amount
case type
when "expense", "transfer_out"
amount.to_d
else
amount.to_d * -1
end
end
end

View file

@ -48,6 +48,10 @@ class Account::Transfer < ApplicationRecord
end
end
def sync_account_later
entries.each(&:sync_account_later)
end
class << self
def build_from_accounts(from_account, to_account, date:, amount:)
outflow = from_account.entries.build \

View file

@ -35,8 +35,9 @@ module Accountable
end
def post_sync
broadcast_remove_to(account, target: "syncing-notification")
broadcast_remove_to(account.family, target: "syncing-notice")
# Broadcast a simple replace event that the controller can handle
broadcast_replace_to(
account,
target: "chart_account_#{account.id}",

View file

@ -15,6 +15,7 @@ class Family < ApplicationRecord
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
has_many :holdings, through: :accounts
has_many :plaid_items, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }

View file

@ -56,7 +56,7 @@ class Investment < ApplicationRecord
end
def post_sync
broadcast_remove_to(account, target: "syncing-notification")
broadcast_remove_to(account, target: "syncing-notice")
broadcast_replace_to(
account,

View file

@ -134,12 +134,12 @@ class Provider::Synth
securities = parsed.dig("data").map do |security|
{
symbol: security.dig("symbol"),
ticker: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_country_code: security.dig("exchange", "country_code")
country_code: security.dig("exchange", "country_code")
}
end

View file

@ -8,17 +8,33 @@ class Security < ApplicationRecord
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
class << self
def search(query)
security_prices_provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country]
).securities.map { |attrs| new(**attrs) }
end
end
def current_price
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
return nil if @current_price.nil?
Money.new(@current_price.price, @current_price.currency)
end
def to_combobox_display
"#{ticker} (#{exchange_acronym})"
def to_combobox_option
SynthComboboxOption.new(
symbol: ticker,
name: name,
logo_url: logo_url,
exchange_acronym: exchange_acronym,
exchange_mic: exchange_mic,
exchange_country_code: country_code
)
end
private
def upcase_ticker

View file

@ -1,22 +1,8 @@
class Security::SynthComboboxOption
include ActiveModel::Model
include Providable
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code
class << self
def find_in_synth(query)
country = Current.family.country
country = "#{country},US" unless country == "US"
security_prices_provider.search_securities(
query:,
dataset: "limited",
country_code: country
).securities.map { |attrs| new(**attrs) }
end
end
def id
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
end