Files
sure/app/models/trade.rb
David Gil 3d91e60a8a feat: Add subcategory breakdown to Cash Flow Sankey and Reports (#639)
* 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>
2026-01-20 00:01:55 +01:00

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