mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Add cost basis source tracking with manual override and lock protection (#623)
* Add cost basis tracking and management to holdings - Added migration to introduce `cost_basis_source` and `cost_basis_locked` fields to `holdings`. - Implemented backfill for existing holdings to set `cost_basis_source` based on heuristics. - Introduced `Holding::CostBasisReconciler` to manage cost basis resolution logic. - Added user interface components for editing and locking cost basis in holdings. - Updated `materializer` to integrate reconciliation logic and respect locked holdings. - Extended tests for cost basis-related workflows to ensure accuracy and reliability. * Fix cost basis calculation in holdings controller - Ensure `cost_basis` is converted to decimal for accurate arithmetic. - Fix conditional check to properly validate positive `cost_basis`. * Improve cost basis validation and error handling in holdings controller - Allow zero as a valid cost basis for gifted/inherited shares. - Add error handling with user feedback for invalid cost basis values. --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
This commit is contained in:
109
app/views/holdings/_cost_basis_cell.html.erb
Normal file
109
app/views/holdings/_cost_basis_cell.html.erb
Normal file
@@ -0,0 +1,109 @@
|
||||
<%# locals: (holding:, editable: true) %>
|
||||
|
||||
<%
|
||||
# Pre-calculate values for the form
|
||||
# Note: cost_basis field stores per-share cost, so calculate total for display
|
||||
current_per_share = holding.cost_basis.present? && holding.cost_basis.positive? ? holding.cost_basis : nil
|
||||
current_total = current_per_share && holding.qty.positive? ? (current_per_share * holding.qty).round(2) : nil
|
||||
currency = Money::Currency.new(holding.currency)
|
||||
%>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding, :cost_basis) do %>
|
||||
<% if holding.cost_basis_locked? && !editable %>
|
||||
<%# Locked and not editable (from holdings list) - just show value, right-aligned %>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<%= tag.span format_money(holding.avg_cost) %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%# Unlocked OR editable context (drawer) - show clickable menu %>
|
||||
<%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %>
|
||||
<% menu.with_button(class: "hover:text-primary cursor-pointer group") do %>
|
||||
<% if holding.avg_cost %>
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.span format_money(holding.avg_cost) %>
|
||||
<% if holding.cost_basis_locked? %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<%= icon "pencil", size: "xs", class: "text-secondary opacity-0 group-hover:opacity-100 transition-opacity" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-1 px-2 py-0.5 rounded text-secondary hover:text-primary hover:bg-gray-100 theme-dark:hover:bg-gray-700 transition-colors">
|
||||
<%= icon "pencil", size: "xs" %>
|
||||
<span class="text-xs">Set</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% menu.with_custom_content do %>
|
||||
<div class="p-4 min-w-[280px]"
|
||||
data-controller="cost-basis-form"
|
||||
data-cost-basis-form-qty-value="<%= holding.qty %>">
|
||||
<h4 class="font-medium text-sm mb-3">
|
||||
<%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %>
|
||||
</h4>
|
||||
<%
|
||||
form_data = { turbo: false }
|
||||
if holding.avg_cost
|
||||
form_data[:turbo_confirm] = {
|
||||
title: t(".overwrite_confirm_title"),
|
||||
body: t(".overwrite_confirm_body", current: format_money(holding.avg_cost))
|
||||
}
|
||||
end
|
||||
%>
|
||||
<%= styled_form_with model: holding,
|
||||
url: holding_path(holding),
|
||||
method: :patch,
|
||||
class: "space-y-3",
|
||||
data: form_data do |f| %>
|
||||
<!-- Primary: Total cost basis (custom input, no spinners) -->
|
||||
<div class="form-field">
|
||||
<div class="form-field__body">
|
||||
<label class="form-field__label"><%= t(".total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
data-action="input->cost-basis-form#updatePerShare"
|
||||
data-cost-basis-form-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2" data-cost-basis-form-target="perShareDisplay">
|
||||
= <%= currency.symbol %><span data-cost-basis-form-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t(".per_share") %>
|
||||
</p>
|
||||
|
||||
<!-- Alternative: Per-share input -->
|
||||
<div class="pt-2 border-t border-tertiary">
|
||||
<label class="text-xs text-secondary block mb-1"><%= t(".or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
data-action="input->cost-basis-form#updateTotal"
|
||||
data-cost-basis-form-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
||||
data-action="click->DS--menu#close">
|
||||
<%= t(".cancel") %>
|
||||
</button>
|
||||
<%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p holding.avg_cost ? format_money(holding.avg_cost) : t(".unknown"), class: holding.avg_cost ? nil : "text-secondary" %>
|
||||
<%= render "holdings/cost_basis_cell", holding: holding, editable: false %>
|
||||
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
||||
</div>
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%# Show Total Return (unrealized G/L) when cost basis exists %>
|
||||
<% if holding.trades.any? && holding.trend %>
|
||||
<%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %>
|
||||
<% 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-secondary" %>
|
||||
<%= tag.p "No cost basis", class: "text-xs text-secondary" %>
|
||||
<%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,16 +35,107 @@
|
||||
<dd class="text-primary"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %></dd>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
||||
<dd class="text-primary"><%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %></dd>
|
||||
<%# Average Cost with inline editor %>
|
||||
<%
|
||||
currency = Money::Currency.new(@holding.currency)
|
||||
current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil
|
||||
current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil
|
||||
%>
|
||||
<div data-controller="drawer-cost-basis" data-drawer-cost-basis-qty-value="<%= @holding.qty %>">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
||||
<dd class="text-primary flex items-center gap-1">
|
||||
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
|
||||
<% if @holding.cost_basis_locked? %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% if @holding.cost_basis_source.present? %>
|
||||
<span class="text-xs text-secondary">(<%= @holding.cost_basis_source_label %>)</span>
|
||||
<% end %>
|
||||
<button type="button" class="ml-1" data-action="click->drawer-cost-basis#toggle">
|
||||
<%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%# Inline cost basis editor (hidden by default) %>
|
||||
<div class="hidden mt-3 space-y-3" data-drawer-cost-basis-target="form">
|
||||
<%
|
||||
drawer_form_data = { turbo: false }
|
||||
if @holding.avg_cost
|
||||
drawer_form_data[:turbo_confirm] = {
|
||||
title: t("holdings.cost_basis_cell.overwrite_confirm_title"),
|
||||
body: t("holdings.cost_basis_cell.overwrite_confirm_body", current: format_money(@holding.avg_cost))
|
||||
}
|
||||
end
|
||||
%>
|
||||
<%= styled_form_with model: @holding,
|
||||
url: holding_path(@holding),
|
||||
method: :patch,
|
||||
class: "space-y-3",
|
||||
data: drawer_form_data do |f| %>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
<%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: number_with_precision(@holding.qty, precision: 4)) %>
|
||||
</p>
|
||||
<!-- Total cost basis input -->
|
||||
<div class="form-field">
|
||||
<div class="form-field__body">
|
||||
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
data-action="input->drawer-cost-basis#updatePerShare"
|
||||
data-drawer-cost-basis-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2">
|
||||
= <%= currency.symbol %><span data-drawer-cost-basis-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t("holdings.cost_basis_cell.per_share") %>
|
||||
</p>
|
||||
|
||||
<!-- Per-share input -->
|
||||
<div class="pt-2 border-t border-tertiary">
|
||||
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
data-action="input->drawer-cost-basis#updateTotal"
|
||||
data-drawer-cost-basis-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
||||
data-action="click->drawer-cost-basis#toggle">
|
||||
<%= t("holdings.cost_basis_cell.cancel") %>
|
||||
</button>
|
||||
<%= f.submit t("holdings.cost_basis_cell.save"), class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".total_return_label") %></dt>
|
||||
<dd style="color: <%= @holding.trend&.color %>;">
|
||||
<%= @holding.trend ? render("shared/trend_change", trend: @holding.trend) : t(".unknown") %>
|
||||
</dd>
|
||||
<% if @holding.trend %>
|
||||
<dd style="color: <%= @holding.trend.color %>;">
|
||||
<%= render("shared/trend_change", trend: @holding.trend) %>
|
||||
</dd>
|
||||
<% else %>
|
||||
<dd class="text-secondary"><%= t(".unknown") %></dd>
|
||||
<% end %>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -85,21 +176,39 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @holding.account.can_delete_holdings? %>
|
||||
<% if @holding.cost_basis_locked? || @holding.account.can_delete_holdings? %>
|
||||
<% dialog.with_section(title: t(".settings"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<% if @holding.cost_basis_locked? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".cost_basis_locked_label") %></h4>
|
||||
<p class="text-secondary"><%= t(".cost_basis_locked_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
||||
</div>
|
||||
<%= button_to t(".unlock_cost_basis"),
|
||||
unlock_cost_basis_holding_path(@holding),
|
||||
method: :post,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
|
||||
form: { data: { turbo: false } },
|
||||
data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @holding.account.can_delete_holdings? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<% investment_metrics[:accounts].each do |account| %>
|
||||
<div class="bg-container-inset rounded-lg p-4 flex items-center justify-between">
|
||||
<%= link_to account_path(account), class: "bg-container-inset rounded-lg p-4 flex items-center justify-between hover:bg-container-hover transition-colors" do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render "accounts/logo", account: account, size: "sm" %>
|
||||
<div>
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-primary"><%= format_money(account.balance_money) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user