mirror of
https://github.com/we-promise/sure.git
synced 2026-04-12 16:47:22 +00:00
* Initial implementation * Tiingo fixes * Adds 2 providers, remove 2 * Add extra checks * FIX a big hotwire race condition // Fix hotwire_combobox race condition: when typing quickly, a slow response for // an early query (e.g. "A") can overwrite the correct results for the final query // (e.g. "AAPL"). We abort the previous in-flight request whenever a new one fires, // so stale Turbo Stream responses never reach the DOM. * pipelock * Update price_test.rb * Reviews * i8n * fixes * fixes * Update tiingo.rb * fixes * Improvements * Big revamp * optimisations * Update 20260408151837_add_offline_reason_to_securities.rb * Add missing tests, fixes * small rank tests * FIX tests * Update show.html.erb * Update resolver.rb * Update usd_converter.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update _yahoo_finance_settings.html.erb
165 lines
5.7 KiB
Ruby
165 lines
5.7 KiB
Ruby
class HoldingsController < ApplicationController
|
|
include StreamExtensions
|
|
|
|
before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security sync_prices]
|
|
before_action :require_holding_write_permission!, only: %i[update destroy unlock_cost_basis remap_security reset_security sync_prices]
|
|
|
|
def index
|
|
@account = accessible_accounts.find(params[:account_id])
|
|
end
|
|
|
|
def show
|
|
@last_price_updated = @holding.security.prices.maximum(:updated_at)
|
|
end
|
|
|
|
def update
|
|
total_cost_basis = holding_params[:cost_basis].to_d
|
|
|
|
if total_cost_basis >= 0 && @holding.qty.positive?
|
|
# Convert total cost basis to per-share cost (the cost_basis field stores per-share)
|
|
# Zero is valid for gifted/inherited shares
|
|
per_share_cost = total_cost_basis / @holding.qty
|
|
@holding.set_manual_cost_basis!(per_share_cost)
|
|
flash[:notice] = t(".success")
|
|
else
|
|
flash[:alert] = t(".error")
|
|
end
|
|
|
|
# Redirect to account page holdings tab to refresh list and close drawer
|
|
redirect_to account_path(@holding.account, tab: "holdings")
|
|
end
|
|
|
|
def unlock_cost_basis
|
|
@holding.unlock_cost_basis!
|
|
flash[:notice] = t(".success")
|
|
|
|
# Redirect to account page holdings tab to refresh list and close drawer
|
|
redirect_to account_path(@holding.account, tab: "holdings")
|
|
end
|
|
|
|
def destroy
|
|
if @holding.account.can_delete_holdings?
|
|
@holding.destroy_holding_and_entries!
|
|
flash[:notice] = t(".success")
|
|
else
|
|
flash[:alert] = "You cannot delete this holding"
|
|
end
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_back_or_to account_path(@holding.account) }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }
|
|
end
|
|
end
|
|
|
|
def remap_security
|
|
# Combobox returns "TICKER|EXCHANGE|PROVIDER" format
|
|
parsed = Security.parse_combobox_id(params[:security_id])
|
|
|
|
# Validate ticker is present (form has required: true, but can be bypassed)
|
|
if parsed[:ticker].blank?
|
|
flash[:alert] = t(".security_not_found")
|
|
redirect_to account_path(@holding.account, tab: "holdings")
|
|
return
|
|
end
|
|
|
|
# The user explicitly selected this security from provider search results,
|
|
# so we use the combobox data directly — no need to re-resolve via provider APIs.
|
|
new_security = Security.find_or_initialize_by(
|
|
ticker: parsed[:ticker],
|
|
exchange_operating_mic: parsed[:exchange_operating_mic]
|
|
)
|
|
|
|
# Honor the user's provider choice (validated by model inclusion check on save)
|
|
new_security.price_provider = parsed[:price_provider] if parsed[:price_provider].present?
|
|
|
|
# Bring it online — user explicitly selected it from provider search results,
|
|
# so we know the provider can handle it.
|
|
new_security.offline = false
|
|
new_security.failed_fetch_count = 0
|
|
new_security.failed_fetch_at = nil
|
|
|
|
new_security.save!
|
|
|
|
@holding.remap_security!(new_security)
|
|
|
|
# Re-materialize holdings with the new security's prices.
|
|
# Reload account to avoid stale associations from remap_security!.
|
|
# The around_action :switch_timezone already sets the family timezone
|
|
# for this request, so Date.current is correct here.
|
|
account = Account.find(@holding.account_id)
|
|
strategy = account.linked? ? :reverse : :forward
|
|
Balance::Materializer.new(account, strategy: strategy, security_ids: [ new_security.id ]).materialize_balances
|
|
|
|
flash[:notice] = t(".success")
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_to account_path(@holding.account, tab: "holdings") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account, tab: "holdings")) }
|
|
end
|
|
end
|
|
|
|
def sync_prices
|
|
security = @holding.security
|
|
|
|
if security.offline?
|
|
redirect_to account_path(@holding.account, tab: "holdings"),
|
|
alert: t("holdings.sync_prices.unavailable")
|
|
return
|
|
end
|
|
|
|
prices_updated, @provider_error = security.import_provider_prices(
|
|
start_date: 31.days.ago.to_date,
|
|
end_date: Date.current,
|
|
clear_cache: true
|
|
)
|
|
security.import_provider_details
|
|
|
|
@last_price_updated = @holding.security.prices.maximum(:updated_at)
|
|
|
|
if prices_updated == 0
|
|
@provider_error = @provider_error.presence || t("holdings.sync_prices.provider_error")
|
|
respond_to do |format|
|
|
format.html { redirect_to account_path(@holding.account, tab: "holdings"), alert: @provider_error }
|
|
format.turbo_stream
|
|
end
|
|
return
|
|
end
|
|
|
|
strategy = @holding.account.linked? ? :reverse : :forward
|
|
Balance::Materializer.new(@holding.account, strategy: strategy, security_ids: [ @holding.security_id ]).materialize_balances
|
|
@holding.reload
|
|
@last_price_updated = @holding.security.prices.maximum(:updated_at)
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_to account_path(@holding.account, tab: "holdings"), notice: t("holdings.sync_prices.success") }
|
|
format.turbo_stream
|
|
end
|
|
end
|
|
|
|
def reset_security
|
|
@holding.reset_security_to_provider!
|
|
flash[:notice] = t(".success")
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_to account_path(@holding.account, tab: "holdings") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account, tab: "holdings")) }
|
|
end
|
|
end
|
|
|
|
private
|
|
def set_holding
|
|
@holding = Current.family.holdings
|
|
.joins(:account)
|
|
.merge(Account.accessible_by(Current.user))
|
|
.find(params[:id])
|
|
end
|
|
|
|
def require_holding_write_permission!
|
|
require_account_permission!(@holding.account)
|
|
end
|
|
|
|
def holding_params
|
|
params.require(:holding).permit(:cost_basis)
|
|
end
|
|
end
|