mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
* feat: Add subcategory breakdown to Cash Flow and Reports Implements Discussion #546 - adds hierarchical category/subcategory visualization to both the Sankey chart and Reports breakdown tables. Sankey chart changes: - Income: subcategory → parent category → Cash Flow - Expense: Cash Flow → parent category → subcategory - Extracted process_category_totals helper to DRY up income/expense logic Reports breakdown changes: - Subcategories display nested under parent categories - Smaller dots and indented rows for visual hierarchy - Extracted _breakdown_table partial to eliminate duplication * fix: Dynamic node padding for Sankey chart with many nodes - Add dynamic nodePadding calculation to prevent padding from dominating chart height when there are many subcategory nodes - Extract magic numbers to static constants for configuration - Decompose monolithic #draw() into focused methods - Consolidate duplicate tooltip/currency formatting code - Modernize syntax with spread operators and optional chaining * fix: Hide overlapping Sankey labels, show on hover - Add label overlap detection by grouping nodes by column depth - Hide labels that would overlap with adjacent nodes - Show hidden labels on hover (node rectangle or connected links) - Add hover events to node rectangles (not just text) * fix: Use deterministic fallback colors for categories - Replace Category::COLORS.sample with Category::UNCATEGORIZED_COLOR for income categories in Sankey chart (was producing different colors on each page load) - Add nil color fallback in reports_controller for parent and root categories Addresses CodeRabbit review feedback. * fix: Expand CSS variable map for d3 color manipulation Add hex mappings for commonly used CSS variables so d3 can manipulate opacity for gradients and hover effects: - var(--color-destructive) -> #EC2222 - var(--color-gray-400) -> #9E9E9E - var(--color-gray-500) -> #737373 * test: Add tests for subcategory breakdown in dashboard and reports - Test dashboard renders Sankey chart with parent/subcategory transactions - Test reports groups transactions by parent and subcategories - Test reports handles categories with nil colors - Use EntriesTestHelper#create_transaction for cleaner test setup * Fix lint: use Number.NEGATIVE_INFINITY * Remove obsolete nil color test Category model now validates color presence, so nil color categories cannot exist. The fallback handling in reports_controller is still in place but the scenario is unreachable. * Update reports_controller.rb * FIX trade category --------- Co-authored-by: sokie <sokysrm@gmail.com>
86 lines
2.4 KiB
Ruby
86 lines
2.4 KiB
Ruby
class Trade < ApplicationRecord
|
|
include Entryable, Monetizable
|
|
|
|
monetize :price
|
|
|
|
belongs_to :security
|
|
belongs_to :category, optional: true
|
|
|
|
# Use the same activity labels as Transaction
|
|
ACTIVITY_LABELS = Transaction::ACTIVITY_LABELS.dup.freeze
|
|
|
|
validates :qty, presence: true
|
|
validates :price, :currency, presence: true
|
|
validates :investment_activity_label, inclusion: { in: ACTIVITY_LABELS }, allow_nil: true
|
|
|
|
# Trade types for categorization
|
|
def buy?
|
|
qty.positive?
|
|
end
|
|
|
|
def sell?
|
|
qty.negative?
|
|
end
|
|
|
|
class << self
|
|
def build_name(type, qty, ticker)
|
|
prefix = type == "buy" ? "Buy" : "Sell"
|
|
"#{prefix} #{qty.to_d.abs} shares of #{ticker}"
|
|
end
|
|
end
|
|
|
|
def unrealized_gain_loss
|
|
return nil if qty.negative?
|
|
current_price = security.current_price
|
|
return nil if current_price.nil?
|
|
|
|
current_value = current_price * qty.abs
|
|
cost_basis = price_money * qty.abs
|
|
|
|
Trend.new(current: current_value, previous: cost_basis)
|
|
end
|
|
|
|
# Calculates realized gain/loss for sell trades based on avg_cost at time of sale
|
|
# Returns nil for buy trades or when cost basis cannot be determined
|
|
def realized_gain_loss
|
|
return @realized_gain_loss if defined?(@realized_gain_loss)
|
|
|
|
@realized_gain_loss = calculate_realized_gain_loss
|
|
end
|
|
|
|
# Trades are always excluded from expense budgets
|
|
# They represent portfolio management, not living expenses
|
|
def excluded_from_budget?
|
|
true
|
|
end
|
|
|
|
private
|
|
|
|
def calculate_realized_gain_loss
|
|
return nil unless sell?
|
|
|
|
# Use preloaded holdings if available (set by reports controller to avoid N+1)
|
|
# Treat defined-but-empty preload as authoritative to prevent DB fallback
|
|
holding = if defined?(@preloaded_holdings)
|
|
# Use select + max_by for deterministic selection regardless of array order
|
|
(@preloaded_holdings || [])
|
|
.select { |h| h.security_id == security_id && h.date <= entry.date }
|
|
.max_by(&:date)
|
|
else
|
|
# Fall back to database query only when not preloaded
|
|
entry.account.holdings
|
|
.where(security_id: security_id)
|
|
.where("date <= ?", entry.date)
|
|
.order(date: :desc)
|
|
.first
|
|
end
|
|
|
|
return nil unless holding&.avg_cost
|
|
|
|
cost_basis = holding.avg_cost * qty.abs
|
|
sale_proceeds = price_money * qty.abs
|
|
|
|
Trend.new(current: sale_proceeds, previous: cost_basis)
|
|
end
|
|
end
|