mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Add investment activity detection, labels, and exclusions
- Introduced `InvestmentActivityDetector` to mark internal investment activity as excluded from cashflow and assign appropriate labels. - Added `exclude_from_cashflow` flag to `entries` and `investment_activity_label` to `transactions` with migrations. - Implemented rake tasks to backfill and clear investment activity labels. - Updated `PlaidAccount::Investments::TransactionsProcessor` to map Plaid transaction types to labels. - Included comprehensive test coverage for new functionality.
This commit is contained in:
@@ -338,7 +338,7 @@ class ReportsController < ApplicationController
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
|
||||
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
@@ -350,7 +350,7 @@ class ReportsController < ApplicationController
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
||||
.where(entries: { entryable_type: "Trade", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
# Get sort parameters
|
||||
@@ -519,7 +519,7 @@ class ReportsController < ApplicationController
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
|
||||
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
@@ -556,7 +556,7 @@ class ReportsController < ApplicationController
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
|
||||
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
@@ -567,7 +567,7 @@ class ReportsController < ApplicationController
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
||||
.where(entries: { entryable_type: "Trade", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
# Group by category, type, and month
|
||||
|
||||
@@ -217,8 +217,8 @@ class TransactionsController < ApplicationController
|
||||
|
||||
def entry_params
|
||||
entry_params = params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
|
||||
:name, :date, :amount, :currency, :excluded, :exclude_from_cashflow, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ]
|
||||
)
|
||||
|
||||
nature = entry_params.delete(:nature)
|
||||
|
||||
@@ -18,8 +18,9 @@ class Account::ProviderImportAdapter
|
||||
# @param notes [String, nil] Optional transaction notes/memo
|
||||
# @param pending_transaction_id [String, nil] Plaid's linking ID for pending→posted reconciliation
|
||||
# @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra
|
||||
# @param investment_activity_label [String, nil] Optional activity type label (e.g., "Buy", "Dividend")
|
||||
# @return [Entry] The created or updated entry
|
||||
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil)
|
||||
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil)
|
||||
raise ArgumentError, "external_id is required" if external_id.blank?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
@@ -114,6 +115,14 @@ class Account::ProviderImportAdapter
|
||||
entry.transaction.extra = existing.deep_merge(incoming)
|
||||
entry.transaction.save!
|
||||
end
|
||||
|
||||
# Set investment activity label if provided and not already set
|
||||
if investment_activity_label.present? && entry.entryable.is_a?(Transaction)
|
||||
if entry.transaction.investment_activity_label.blank?
|
||||
entry.transaction.update!(investment_activity_label: investment_activity_label)
|
||||
end
|
||||
end
|
||||
|
||||
entry.save!
|
||||
|
||||
# AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim)
|
||||
|
||||
@@ -47,8 +47,9 @@ class IncomeStatement::CategoryStats
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND ae.exclude_from_cashflow = false
|
||||
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
|
||||
@@ -44,8 +44,9 @@ class IncomeStatement::FamilyStats
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND ae.exclude_from_cashflow = false
|
||||
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
|
||||
@@ -69,8 +69,9 @@ class IncomeStatement::Totals
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND ae.exclude_from_cashflow = false
|
||||
AND a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
|
||||
@@ -95,8 +96,9 @@ class IncomeStatement::Totals
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND ae.exclude_from_cashflow = false
|
||||
AND a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
@@ -126,6 +128,7 @@ class IncomeStatement::Totals
|
||||
WHERE a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
AND ae.excluded = false
|
||||
AND ae.exclude_from_cashflow = false
|
||||
AND ae.date BETWEEN :start_date AND :end_date
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
|
||||
SQL
|
||||
|
||||
346
app/models/investment_activity_detector.rb
Normal file
346
app/models/investment_activity_detector.rb
Normal file
@@ -0,0 +1,346 @@
|
||||
# Detects internal investment activity (fund swaps, reinvestments) by comparing
|
||||
# holdings snapshots between syncs and marks matching transactions as excluded
|
||||
# from cashflow. This is provider-agnostic and works with any holdings data.
|
||||
#
|
||||
# Usage:
|
||||
# detector = InvestmentActivityDetector.new(account)
|
||||
# detector.detect_and_mark_internal_activity(current_holdings, recent_transactions)
|
||||
#
|
||||
class InvestmentActivityDetector
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
# Class method for inferring activity label from description and amount
|
||||
# without needing a full detector instance
|
||||
# @param name [String] Transaction name/description
|
||||
# @param amount [Numeric] Transaction amount
|
||||
# @param account [Account, nil] Optional account for context (e.g., retirement plan detection)
|
||||
# @return [String, nil] Activity label or nil if unknown
|
||||
def self.infer_label_from_description(name, amount, account = nil)
|
||||
new(nil).send(:infer_from_description, name, amount, account)
|
||||
end
|
||||
|
||||
# Call this after syncing transactions for an investment/crypto account
|
||||
# @param current_holdings [Array] Array of holding objects/hashes from provider
|
||||
# @param recent_transactions [Array<Transaction>] Recently imported transactions
|
||||
def detect_and_mark_internal_activity(current_holdings, recent_transactions)
|
||||
return unless @account.investment? || @account.crypto?
|
||||
return if current_holdings.blank?
|
||||
|
||||
previous_snapshot = @account.holdings_snapshot_data || []
|
||||
|
||||
# Find holdings changes that indicate buys/sells
|
||||
changes = detect_holdings_changes(previous_snapshot, current_holdings)
|
||||
|
||||
# Match changes to transactions and mark them as excluded
|
||||
changes.each do |change|
|
||||
matched_entry = find_matching_entry(change, recent_transactions)
|
||||
next unless matched_entry
|
||||
|
||||
transaction = matched_entry.entryable
|
||||
|
||||
# Only auto-set if not already manually set by user (respect user overrides)
|
||||
unless matched_entry.locked?(:exclude_from_cashflow)
|
||||
matched_entry.update!(exclude_from_cashflow: true)
|
||||
matched_entry.lock_attr!(:exclude_from_cashflow)
|
||||
|
||||
Rails.logger.info(
|
||||
"InvestmentActivityDetector: Auto-excluded entry #{matched_entry.id} " \
|
||||
"(#{matched_entry.name}) as internal #{change[:type]} of #{change[:symbol] || change[:description]}"
|
||||
)
|
||||
end
|
||||
|
||||
# Set activity label if not already set
|
||||
if transaction.is_a?(Transaction) && transaction.investment_activity_label.blank?
|
||||
label = infer_activity_label(matched_entry, change[:type])
|
||||
transaction.update!(investment_activity_label: label) if label.present?
|
||||
end
|
||||
end
|
||||
|
||||
# Store current snapshot for next comparison
|
||||
save_holdings_snapshot(current_holdings)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Infer activity label from change type and transaction description
|
||||
def infer_activity_label(entry, change_type)
|
||||
# If we know it's a buy or sell from holdings comparison
|
||||
return "Buy" if change_type == :buy
|
||||
return "Sell" if change_type == :sell
|
||||
|
||||
# Otherwise try to infer from description
|
||||
infer_from_description(entry)
|
||||
end
|
||||
|
||||
# Infer activity label from transaction description
|
||||
# Can be called with an Entry or with name/amount directly
|
||||
# @param entry_or_name [Entry, String] Entry object or transaction name
|
||||
# @param amount [Numeric, nil] Transaction amount (required if entry_or_name is String)
|
||||
# @param account [Account, nil] Optional account for context (e.g., retirement plan detection)
|
||||
def infer_from_description(entry_or_name, amount = nil, account = nil)
|
||||
if entry_or_name.respond_to?(:name)
|
||||
description = (entry_or_name.name || "").upcase
|
||||
amount = entry_or_name.amount || 0
|
||||
account ||= entry_or_name.try(:account)
|
||||
else
|
||||
description = (entry_or_name || "").upcase
|
||||
amount ||= 0
|
||||
end
|
||||
|
||||
# Check if this is a retirement plan account (401k, 403b, etc.)
|
||||
account_name = (account&.name || "").upcase
|
||||
retirement_indicators = %w[401K 403B RETIREMENT TOTALSOURCE NETBENEFITS]
|
||||
retirement_phrases = [ "SAVINGS PLAN", "THRIFT PLAN", "PENSION" ]
|
||||
is_retirement_plan = retirement_indicators.any? { |ind| account_name.include?(ind) } ||
|
||||
retirement_phrases.any? { |phrase| account_name.include?(phrase) }
|
||||
|
||||
# Check for sweep/money market patterns (but NOT money market FUND purchases)
|
||||
# INVESTOR CL indicates this is a money market fund, not a sweep
|
||||
sweep_patterns = %w[SWEEP SETTLEMENT]
|
||||
money_market_sweep = description.include?("MONEY MARKET") && !description.include?("INVESTOR")
|
||||
common_money_market_tickers = %w[VMFXX SPAXX FDRXX SWVXX SPRXX]
|
||||
|
||||
if sweep_patterns.any? { |p| description.include?(p) } ||
|
||||
money_market_sweep ||
|
||||
common_money_market_tickers.any? { |t| description == t }
|
||||
return amount.positive? ? "Sweep Out" : "Sweep In"
|
||||
end
|
||||
|
||||
# Check for likely interest/dividend on money market funds
|
||||
# Small amounts (under $5) on money market funds are typically interest income
|
||||
money_market_fund_patterns = %w[MONEY\ MARKET VMFXX SPAXX FDRXX SWVXX SPRXX VUSXX]
|
||||
is_money_market_fund = money_market_fund_patterns.any? { |p| description.include?(p) }
|
||||
|
||||
if is_money_market_fund && amount.abs < 5
|
||||
# Small money market amounts are interest, not buys/sells
|
||||
return "Interest"
|
||||
end
|
||||
|
||||
# Check for dividend patterns
|
||||
if description == "CASH" || description.include?("DIVIDEND") ||
|
||||
description.include?("DISTRIBUTION")
|
||||
return "Dividend"
|
||||
end
|
||||
|
||||
# Check for interest
|
||||
return "Interest" if description.include?("INTEREST")
|
||||
|
||||
# Check for fees
|
||||
return "Fee" if description.include?("FEE") || description.include?("CHARGE")
|
||||
|
||||
# Check for reinvestment
|
||||
return "Reinvestment" if description.include?("REINVEST")
|
||||
|
||||
# Check for exchange/conversion
|
||||
return "Exchange" if description.include?("EXCHANGE") || description.include?("CONVERSION")
|
||||
|
||||
# Check for contribution patterns
|
||||
return "Contribution" if description.include?("CONTRIBUTION") || description.include?("DEPOSIT")
|
||||
|
||||
# Check for withdrawal patterns
|
||||
return "Withdrawal" if description.include?("WITHDRAWAL") || description.include?("DISBURSEMENT")
|
||||
|
||||
# Check for fund names that indicate buy/sell activity
|
||||
# Positive amount = money out from account perspective = buying securities
|
||||
# Negative amount = money in = selling securities
|
||||
fund_patterns = %w[
|
||||
INDEX FUND ADMIRAL ETF SHARES TRUST
|
||||
VANGUARD FIDELITY SCHWAB ISHARES SPDR
|
||||
500\ INDEX TOTAL\ MARKET GROWTH BOND
|
||||
]
|
||||
|
||||
# Common fund ticker patterns
|
||||
fund_ticker_patterns = %w[
|
||||
VFIAX VTSAX VXUS VBTLX VTIAX VTTVX
|
||||
VTI VOO VGT VIG VYM VGIT
|
||||
FXAIX FZROX FSKAX FBALX
|
||||
SWTSX SWPPX SCHD SCHX
|
||||
SPY QQQ IVV AGG
|
||||
IBIT GBTC ETHE
|
||||
]
|
||||
|
||||
is_fund_transaction = fund_patterns.any? { |p| description.include?(p) } ||
|
||||
fund_ticker_patterns.any? { |t| description.include?(t) }
|
||||
|
||||
if is_fund_transaction
|
||||
if is_retirement_plan && amount.negative?
|
||||
# Negative amount in retirement plan = payroll contribution buying shares
|
||||
return "Contribution"
|
||||
else
|
||||
return amount.positive? ? "Buy" : "Sell"
|
||||
end
|
||||
end
|
||||
|
||||
nil # Unknown - user can set manually
|
||||
end
|
||||
|
||||
def detect_holdings_changes(previous, current)
|
||||
changes = []
|
||||
|
||||
current.each do |holding|
|
||||
prev = find_previous_holding(previous, holding)
|
||||
|
||||
if prev.nil?
|
||||
# New holding appeared = BUY
|
||||
changes << {
|
||||
type: :buy,
|
||||
symbol: holding_symbol(holding),
|
||||
description: holding_description(holding),
|
||||
shares: holding_shares(holding),
|
||||
cost_basis: holding_cost_basis(holding),
|
||||
created_at: holding_created_at(holding)
|
||||
}
|
||||
elsif holding_shares(holding) > prev_shares(prev)
|
||||
# Shares increased = BUY
|
||||
changes << {
|
||||
type: :buy,
|
||||
symbol: holding_symbol(holding),
|
||||
description: holding_description(holding),
|
||||
shares_delta: holding_shares(holding) - prev_shares(prev),
|
||||
cost_basis_delta: holding_cost_basis(holding) - prev_cost_basis(prev)
|
||||
}
|
||||
elsif holding_shares(holding) < prev_shares(prev)
|
||||
# Shares decreased = SELL
|
||||
changes << {
|
||||
type: :sell,
|
||||
symbol: holding_symbol(holding),
|
||||
description: holding_description(holding),
|
||||
shares_delta: prev_shares(prev) - holding_shares(holding)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Check for holdings that completely disappeared = SELL ALL
|
||||
previous.each do |prev|
|
||||
unless current.any? { |h| same_holding?(h, prev) }
|
||||
changes << {
|
||||
type: :sell,
|
||||
symbol: prev_symbol(prev),
|
||||
description: prev_description(prev),
|
||||
shares: prev_shares(prev)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
changes
|
||||
end
|
||||
|
||||
def find_matching_entry(change, transactions)
|
||||
transactions.each do |txn|
|
||||
entry = txn.respond_to?(:entry) ? txn.entry : txn
|
||||
next unless entry
|
||||
next if entry.exclude_from_cashflow? # Already excluded
|
||||
|
||||
# Match by cost_basis amount (for buys with known cost)
|
||||
if change[:cost_basis].present? && change[:cost_basis].to_d > 0
|
||||
amount_diff = (entry.amount.to_d.abs - change[:cost_basis].to_d.abs).abs
|
||||
return entry if amount_diff < 0.01
|
||||
end
|
||||
|
||||
# Match by cost_basis delta (for additional buys)
|
||||
if change[:cost_basis_delta].present? && change[:cost_basis_delta].to_d > 0
|
||||
amount_diff = (entry.amount.to_d.abs - change[:cost_basis_delta].to_d.abs).abs
|
||||
return entry if amount_diff < 0.01
|
||||
end
|
||||
|
||||
# Match by description containing security name/symbol
|
||||
entry_desc = entry.name&.downcase || ""
|
||||
|
||||
if change[:symbol].present?
|
||||
return entry if entry_desc.include?(change[:symbol].downcase)
|
||||
end
|
||||
|
||||
if change[:description].present?
|
||||
# Match first few words of description for fuzzy matching
|
||||
desc_words = change[:description].downcase.split.first(3).join(" ")
|
||||
return entry if desc_words.present? && entry_desc.include?(desc_words)
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def find_previous_holding(previous, current)
|
||||
symbol = holding_symbol(current)
|
||||
return previous.find { |p| prev_symbol(p) == symbol } if symbol.present?
|
||||
|
||||
# Fallback to description matching if no symbol
|
||||
desc = holding_description(current)
|
||||
previous.find { |p| prev_description(p) == desc } if desc.present?
|
||||
end
|
||||
|
||||
def same_holding?(current, previous)
|
||||
current_symbol = holding_symbol(current)
|
||||
prev_sym = prev_symbol(previous)
|
||||
|
||||
if current_symbol.present? && prev_sym.present?
|
||||
current_symbol == prev_sym
|
||||
else
|
||||
holding_description(current) == prev_description(previous)
|
||||
end
|
||||
end
|
||||
|
||||
def save_holdings_snapshot(holdings)
|
||||
snapshot_data = holdings.map do |h|
|
||||
{
|
||||
"symbol" => holding_symbol(h),
|
||||
"description" => holding_description(h),
|
||||
"shares" => holding_shares(h).to_s,
|
||||
"cost_basis" => holding_cost_basis(h).to_s,
|
||||
"market_value" => holding_market_value(h).to_s
|
||||
}
|
||||
end
|
||||
|
||||
@account.update!(
|
||||
holdings_snapshot_data: snapshot_data,
|
||||
holdings_snapshot_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Normalize access - holdings could be AR objects or hashes from different providers
|
||||
def holding_symbol(h)
|
||||
h.try(:symbol) || h.try(:ticker) || h["symbol"] || h[:symbol] || h["ticker"] || h[:ticker]
|
||||
end
|
||||
|
||||
def holding_description(h)
|
||||
h.try(:description) || h.try(:name) || h["description"] || h[:description] || h["name"] || h[:name]
|
||||
end
|
||||
|
||||
def holding_shares(h)
|
||||
val = h.try(:shares) || h.try(:qty) || h["shares"] || h[:shares] || h["qty"] || h[:qty]
|
||||
val.to_d
|
||||
end
|
||||
|
||||
def holding_cost_basis(h)
|
||||
val = h.try(:cost_basis) || h["cost_basis"] || h[:cost_basis]
|
||||
val.to_d
|
||||
end
|
||||
|
||||
def holding_market_value(h)
|
||||
val = h.try(:market_value) || h.try(:amount) || h["market_value"] || h[:market_value] || h["amount"] || h[:amount]
|
||||
val.to_d
|
||||
end
|
||||
|
||||
def holding_created_at(h)
|
||||
h.try(:created_at) || h["created"] || h[:created] || h["created_at"] || h[:created_at]
|
||||
end
|
||||
|
||||
# Previous snapshot accessor methods (snapshot is always a hash)
|
||||
def prev_symbol(p)
|
||||
p["symbol"] || p[:symbol]
|
||||
end
|
||||
|
||||
def prev_description(p)
|
||||
p["description"] || p[:description]
|
||||
end
|
||||
|
||||
def prev_shares(p)
|
||||
(p["shares"] || p[:shares]).to_d
|
||||
end
|
||||
|
||||
def prev_cost_basis(p)
|
||||
(p["cost_basis"] || p[:cost_basis]).to_d
|
||||
end
|
||||
end
|
||||
@@ -74,10 +74,37 @@ class LunchflowAccount::Processor
|
||||
return unless [ "Investment", "Crypto" ].include?(lunchflow_account.current_account&.accountable_type)
|
||||
|
||||
LunchflowAccount::Investments::HoldingsProcessor.new(lunchflow_account).process
|
||||
|
||||
# Detect and mark internal investment activity (fund swaps, reinvestments)
|
||||
detect_internal_investment_activity
|
||||
rescue => e
|
||||
report_exception(e, "holdings")
|
||||
end
|
||||
|
||||
def detect_internal_investment_activity
|
||||
account = lunchflow_account.current_account
|
||||
return unless account&.investment? || account&.crypto?
|
||||
|
||||
# Get current holdings from raw payload
|
||||
current_holdings = lunchflow_account.raw_holdings_payload || []
|
||||
return if current_holdings.blank?
|
||||
|
||||
# Get recent transactions (last 30 days to catch any we might have missed)
|
||||
recent_transactions = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(date: 30.days.ago.to_date..Date.current)
|
||||
.where(exclude_from_cashflow: false)
|
||||
.map(&:entryable)
|
||||
.compact
|
||||
|
||||
InvestmentActivityDetector.new(account).detect_and_mark_internal_activity(
|
||||
current_holdings,
|
||||
recent_transactions
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.warn("InvestmentActivityDetector failed for Lunchflow account #{lunchflow_account.id}: #{e.message}")
|
||||
end
|
||||
|
||||
def report_exception(error, context)
|
||||
Sentry.capture_exception(error) do |scope|
|
||||
scope.set_tags(
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
class PlaidAccount::Investments::TransactionsProcessor
|
||||
SecurityNotFoundError = Class.new(StandardError)
|
||||
|
||||
# Map Plaid investment transaction types to activity labels
|
||||
PLAID_TYPE_TO_LABEL = {
|
||||
"buy" => "Buy",
|
||||
"sell" => "Sell",
|
||||
"cancel" => "Cancelled",
|
||||
"cash" => "Cash",
|
||||
"fee" => "Fee",
|
||||
"transfer" => "Transfer",
|
||||
"dividend" => "Dividend",
|
||||
"interest" => "Interest",
|
||||
"contribution" => "Contribution",
|
||||
"withdrawal" => "Withdrawal",
|
||||
"dividend reinvestment" => "Reinvestment",
|
||||
"spin off" => "Other",
|
||||
"split" => "Other"
|
||||
}.freeze
|
||||
|
||||
def initialize(plaid_account, security_resolver:)
|
||||
@plaid_account = plaid_account
|
||||
@security_resolver = security_resolver
|
||||
@@ -68,10 +85,16 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||
currency: transaction["iso_currency_code"],
|
||||
date: transaction["date"],
|
||||
name: transaction["name"],
|
||||
source: "plaid"
|
||||
source: "plaid",
|
||||
investment_activity_label: label_from_plaid_type(transaction)
|
||||
)
|
||||
end
|
||||
|
||||
def label_from_plaid_type(transaction)
|
||||
plaid_type = transaction["type"]&.downcase
|
||||
PLAID_TYPE_TO_LABEL[plaid_type] || plaid_type&.titleize
|
||||
end
|
||||
|
||||
def transactions
|
||||
plaid_account.raw_investments_payload["transactions"] || []
|
||||
end
|
||||
|
||||
@@ -103,10 +103,50 @@ class PlaidAccount::Processor
|
||||
def process_investments
|
||||
PlaidAccount::Investments::TransactionsProcessor.new(plaid_account, security_resolver: security_resolver).process
|
||||
PlaidAccount::Investments::HoldingsProcessor.new(plaid_account, security_resolver: security_resolver).process
|
||||
|
||||
# Detect and mark internal investment activity (fund swaps, reinvestments)
|
||||
# Note: Plaid already creates Trade entries for buy/sell, but this catches cash transactions
|
||||
detect_internal_investment_activity
|
||||
rescue => e
|
||||
report_exception(e)
|
||||
end
|
||||
|
||||
def detect_internal_investment_activity
|
||||
account = AccountProvider.find_by(provider: plaid_account)&.account
|
||||
return unless account&.investment? || account&.crypto?
|
||||
|
||||
# Get current holdings from raw payload
|
||||
raw_holdings = plaid_account.raw_investments_payload&.dig("holdings") || []
|
||||
return if raw_holdings.blank?
|
||||
|
||||
# Transform to common format
|
||||
current_holdings = raw_holdings.map do |h|
|
||||
security = security_resolver.resolve(h["security_id"])
|
||||
{
|
||||
"symbol" => security&.ticker,
|
||||
"description" => security&.name,
|
||||
"shares" => h["quantity"],
|
||||
"cost_basis" => h["cost_basis"],
|
||||
"market_value" => h["institution_value"]
|
||||
}
|
||||
end
|
||||
|
||||
# Get recent transactions (last 30 days to catch any we might have missed)
|
||||
recent_transactions = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(date: 30.days.ago.to_date..Date.current)
|
||||
.where(exclude_from_cashflow: false)
|
||||
.map(&:entryable)
|
||||
.compact
|
||||
|
||||
InvestmentActivityDetector.new(account).detect_and_mark_internal_activity(
|
||||
current_holdings,
|
||||
recent_transactions
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.warn("InvestmentActivityDetector failed for Plaid account #{plaid_account.id}: #{e.message}")
|
||||
end
|
||||
|
||||
def process_liabilities
|
||||
case [ plaid_account.plaid_type, plaid_account.plaid_subtype ]
|
||||
when [ "credit", "credit card" ]
|
||||
|
||||
@@ -151,10 +151,37 @@ class SimplefinAccount::Processor
|
||||
return unless simplefin_account.current_account&.accountable_type == "Investment"
|
||||
SimplefinAccount::Investments::TransactionsProcessor.new(simplefin_account).process
|
||||
SimplefinAccount::Investments::HoldingsProcessor.new(simplefin_account).process
|
||||
|
||||
# Detect and mark internal investment activity (fund swaps, reinvestments)
|
||||
detect_internal_investment_activity
|
||||
rescue => e
|
||||
report_exception(e, "investments")
|
||||
end
|
||||
|
||||
def detect_internal_investment_activity
|
||||
account = simplefin_account.current_account
|
||||
return unless account&.investment? || account&.crypto?
|
||||
|
||||
# Get current holdings from raw payload
|
||||
current_holdings = simplefin_account.raw_holdings_payload || []
|
||||
return if current_holdings.blank?
|
||||
|
||||
# Get recent transactions (last 30 days to catch any we might have missed)
|
||||
recent_transactions = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(date: 30.days.ago.to_date..Date.current)
|
||||
.where(exclude_from_cashflow: false)
|
||||
.map(&:entryable)
|
||||
.compact
|
||||
|
||||
InvestmentActivityDetector.new(account).detect_and_mark_internal_activity(
|
||||
current_holdings,
|
||||
recent_transactions
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.warn("InvestmentActivityDetector failed for account #{simplefin_account.current_account&.id}: #{e.message}")
|
||||
end
|
||||
|
||||
def process_liabilities
|
||||
case simplefin_account.current_account&.accountable_type
|
||||
when "CreditCard"
|
||||
|
||||
@@ -18,7 +18,8 @@ class SimplefinEntry::Processor
|
||||
source: "simplefin",
|
||||
merchant: merchant,
|
||||
notes: notes,
|
||||
extra: extra_metadata
|
||||
extra: extra_metadata,
|
||||
investment_activity_label: inferred_activity_label
|
||||
)
|
||||
end
|
||||
|
||||
@@ -204,4 +205,11 @@ class SimplefinEntry::Processor
|
||||
end
|
||||
parts.presence&.join(" | ")
|
||||
end
|
||||
|
||||
# Infer investment activity label from transaction description
|
||||
# Only returns a label for investment/crypto accounts
|
||||
def inferred_activity_label
|
||||
return nil unless account&.investment? || account&.crypto?
|
||||
InvestmentActivityDetector.infer_label_from_description(name, amount, account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,9 +16,23 @@ class Transaction < ApplicationRecord
|
||||
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
||||
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
|
||||
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
|
||||
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
|
||||
one_time: "one_time", # A one-time expense/income, excluded from budget analytics
|
||||
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, included in budget as investment expense
|
||||
}
|
||||
|
||||
# Labels for internal investment activity (auto-exclude from cashflow)
|
||||
# Only internal shuffling should be excluded, not contributions/dividends/withdrawals
|
||||
INTERNAL_ACTIVITY_LABELS = %w[Buy Sell Reinvestment Exchange].freeze
|
||||
|
||||
# All valid investment activity labels (for UI dropdown)
|
||||
ACTIVITY_LABELS = [
|
||||
"Buy", "Sell", "Sweep In", "Sweep Out", "Dividend", "Reinvestment",
|
||||
"Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other"
|
||||
].freeze
|
||||
|
||||
after_save :sync_exclude_from_cashflow_with_activity_label,
|
||||
if: :saved_change_to_investment_activity_label?
|
||||
|
||||
# Pending transaction scopes - filter based on provider pending flags in extra JSONB
|
||||
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
|
||||
scope :pending, -> {
|
||||
@@ -145,4 +159,17 @@ class Transaction < ApplicationRecord
|
||||
|
||||
FamilyMerchantAssociation.where(family: family, merchant: merchant).delete_all
|
||||
end
|
||||
|
||||
# Sync exclude_from_cashflow based on activity label
|
||||
# Internal activities (Buy, Sell, etc.) should be excluded from cashflow
|
||||
def sync_exclude_from_cashflow_with_activity_label
|
||||
return unless entry&.account&.investment? || entry&.account&.crypto?
|
||||
return if entry.locked?(:exclude_from_cashflow) # Respect user's manual setting
|
||||
|
||||
should_exclude = INTERNAL_ACTIVITY_LABELS.include?(investment_activity_label)
|
||||
|
||||
if entry.exclude_from_cashflow != should_exclude
|
||||
entry.update!(exclude_from_cashflow: should_exclude)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,8 +49,8 @@ class Transaction::Search
|
||||
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
|
||||
result = transactions_scope
|
||||
.select(
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') AND entries.exclude_from_cashflow = false THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') AND entries.exclude_from_cashflow = false THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COUNT(entries.id) as transactions_count"
|
||||
)
|
||||
.joins(
|
||||
@@ -100,14 +100,14 @@ class Transaction::Search
|
||||
if parent_category_ids.empty?
|
||||
query = query.left_joins(:category).where(
|
||||
"categories.name IN (?) OR (
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
|
||||
)",
|
||||
categories
|
||||
)
|
||||
else
|
||||
query = query.left_joins(:category).where(
|
||||
"categories.name IN (?) OR categories.parent_id IN (?) OR (
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
|
||||
)",
|
||||
categories, parent_category_ids
|
||||
)
|
||||
|
||||
@@ -16,6 +16,10 @@ class Transfer < ApplicationRecord
|
||||
def kind_for_account(account)
|
||||
if account.loan?
|
||||
"loan_payment"
|
||||
elsif account.credit_card?
|
||||
"cc_payment"
|
||||
elsif account.investment? || account.crypto?
|
||||
"investment_contribution"
|
||||
elsif account.liability?
|
||||
"cc_payment"
|
||||
else
|
||||
|
||||
@@ -78,6 +78,19 @@
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if entry.exclude_from_cashflow? %>
|
||||
<span class="text-secondary" title="<%= t('transactions.transaction.excluded_from_cashflow_tooltip') %>">
|
||||
<%= icon "eye-off", size: "sm", color: "current" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Investment activity label badge %>
|
||||
<% if transaction.investment_activity_label.present? %>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-alpha-black-50 text-secondary" title="<%= t("transactions.transaction.activity_type_tooltip") %>">
|
||||
<%= transaction.investment_activity_label %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Pending indicator %>
|
||||
<% if transaction.pending? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">
|
||||
|
||||
@@ -194,8 +194,8 @@
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary">Exclude</h4>
|
||||
<p class="text-secondary">Excluded transactions will be removed from budgeting calculations and reports.</p>
|
||||
<h4 class="text-primary"><%= t(".exclude") %></h4>
|
||||
<p class="text-secondary"><%= t(".exclude_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
|
||||
@@ -203,6 +203,55 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".exclude_from_cashflow") %></h4>
|
||||
<p class="text-secondary">
|
||||
<% if @entry.account.investment? || @entry.account.crypto? %>
|
||||
<%= t(".exclude_from_cashflow_description_investment") %>
|
||||
<% else %>
|
||||
<%= t(".exclude_from_cashflow_description") %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= f.toggle :exclude_from_cashflow, { data: { auto_submit_form_target: "auto" } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entry.account.investment? || @entry.account.crypto? %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".activity_type") %></h4>
|
||||
<p class="text-secondary"><%= t(".activity_type_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= ef.select :investment_activity_label,
|
||||
options_for_select(
|
||||
[["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [l, l] },
|
||||
@entry.entryable.investment_activity_label
|
||||
),
|
||||
{ label: false },
|
||||
{ class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm",
|
||||
data: { auto_submit_form_target: "auto" } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
|
||||
@@ -31,6 +31,13 @@ en:
|
||||
balances, and cannot be undone.
|
||||
delete_title: Delete transaction
|
||||
details: Details
|
||||
exclude: Exclude
|
||||
exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
|
||||
exclude_from_cashflow: Exclude from Cashflow
|
||||
exclude_from_cashflow_description: Hide from income/expense reports and Sankey chart. Useful for transactions you don't want in cashflow analysis.
|
||||
exclude_from_cashflow_description_investment: Hide from income/expense reports and Sankey chart. Use for internal investment activity like fund swaps, reinvestments, or money market sweeps.
|
||||
activity_type: Activity Type
|
||||
activity_type_description: Type of investment activity (Buy, Sell, Dividend, etc.). Auto-detected or set manually.
|
||||
mark_recurring: Mark as Recurring
|
||||
mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
|
||||
mark_recurring_title: Recurring Transaction
|
||||
@@ -48,9 +55,13 @@ en:
|
||||
potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
|
||||
merge_duplicate: Yes, merge them
|
||||
keep_both: No, keep both
|
||||
loan_payment: Loan Payment
|
||||
transfer: Transfer
|
||||
transaction:
|
||||
pending: Pending
|
||||
pending_tooltip: Pending transaction — may change when posted
|
||||
excluded_from_cashflow_tooltip: Excluded from cashflow reports
|
||||
activity_type_tooltip: Investment activity type
|
||||
possible_duplicate: Duplicate?
|
||||
potential_duplicate_tooltip: This may be a duplicate of another transaction
|
||||
review_recommended: Review
|
||||
|
||||
13
db/migrate/20260110120000_add_investment_cashflow_support.rb
Normal file
13
db/migrate/20260110120000_add_investment_cashflow_support.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class AddInvestmentCashflowSupport < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Flag for excluding from cashflow (user-controllable)
|
||||
# Used for internal investment activity like fund swaps
|
||||
add_column :entries, :exclude_from_cashflow, :boolean, default: false, null: false
|
||||
add_index :entries, :exclude_from_cashflow
|
||||
|
||||
# Holdings snapshot for comparison (provider-agnostic)
|
||||
# Used to detect internal investment activity by comparing holdings between syncs
|
||||
add_column :accounts, :holdings_snapshot_data, :jsonb
|
||||
add_column :accounts, :holdings_snapshot_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddInvestmentActivityLabelToTransactions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Label for investment activity type (Buy, Sell, Sweep In, Dividend, etc.)
|
||||
# Provides human-readable context for why a transaction is excluded from cashflow
|
||||
add_column :transactions, :investment_activity_label, :string
|
||||
add_index :transactions, :investment_activity_label
|
||||
end
|
||||
end
|
||||
62
db/schema.rb
generated
62
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_10_180000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -476,22 +476,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
|
||||
end
|
||||
|
||||
create_table "flipper_features", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["key"], name: "index_flipper_features_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "flipper_gates", force: :cascade do |t|
|
||||
t.string "feature_key", null: false
|
||||
t.string "key", null: false
|
||||
t.text "value"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
|
||||
end
|
||||
|
||||
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "security_id", null: false
|
||||
@@ -505,8 +489,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
t.string "external_id"
|
||||
t.decimal "cost_basis", precision: 19, scale: 4
|
||||
t.uuid "account_provider_id"
|
||||
t.string "cost_basis_source"
|
||||
t.boolean "cost_basis_locked", default: false, null: false
|
||||
t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)"
|
||||
t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true
|
||||
t.index ["account_id"], name: "index_holdings_on_account_id"
|
||||
@@ -816,8 +798,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
t.datetime "last_authenticated_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "issuer"
|
||||
t.index ["issuer"], name: "index_oidc_identities_on_issuer"
|
||||
t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_identities_on_user_id"
|
||||
end
|
||||
@@ -1073,38 +1053,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
t.index ["status"], name: "index_simplefin_items_on_status"
|
||||
end
|
||||
|
||||
create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id"
|
||||
t.string "event_type", null: false
|
||||
t.string "provider"
|
||||
t.string "ip_address"
|
||||
t.string "user_agent"
|
||||
t.jsonb "metadata", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_at"], name: "index_sso_audit_logs_on_created_at"
|
||||
t.index ["event_type"], name: "index_sso_audit_logs_on_event_type"
|
||||
t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at"
|
||||
t.index ["user_id"], name: "index_sso_audit_logs_on_user_id"
|
||||
end
|
||||
|
||||
create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "strategy", null: false
|
||||
t.string "name", null: false
|
||||
t.string "label", null: false
|
||||
t.string "icon"
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.string "issuer"
|
||||
t.string "client_id"
|
||||
t.string "client_secret"
|
||||
t.string "redirect_uri"
|
||||
t.jsonb "settings", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["enabled"], name: "index_sso_providers_on_enabled"
|
||||
t.index ["name"], name: "index_sso_providers_on_name", unique: true
|
||||
end
|
||||
|
||||
create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "status", null: false
|
||||
@@ -1181,14 +1129,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
t.string "currency"
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.uuid "category_id"
|
||||
t.decimal "realized_gain", precision: 19, scale: 4
|
||||
t.decimal "cost_basis_amount", precision: 19, scale: 4
|
||||
t.string "cost_basis_currency"
|
||||
t.integer "holding_period_days"
|
||||
t.string "realized_gain_confidence"
|
||||
t.string "realized_gain_currency"
|
||||
t.index ["category_id"], name: "index_trades_on_category_id"
|
||||
t.index ["realized_gain"], name: "index_trades_on_realized_gain_not_null", where: "(realized_gain IS NOT NULL)"
|
||||
t.index ["security_id"], name: "index_trades_on_security_id"
|
||||
end
|
||||
|
||||
@@ -1339,7 +1280,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
add_foreign_key "sessions", "users"
|
||||
add_foreign_key "simplefin_accounts", "simplefin_items"
|
||||
add_foreign_key "simplefin_items", "families"
|
||||
add_foreign_key "sso_audit_logs", "users"
|
||||
add_foreign_key "subscriptions", "families"
|
||||
add_foreign_key "syncs", "syncs", column: "parent_id"
|
||||
add_foreign_key "taggings", "tags"
|
||||
|
||||
185
lib/tasks/investment_labels.rake
Normal file
185
lib/tasks/investment_labels.rake
Normal file
@@ -0,0 +1,185 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Backfill investment activity labels for existing transactions
|
||||
#
|
||||
# Usage examples:
|
||||
# # Preview (dry run) - show what labels would be set
|
||||
# bin/rails 'sure:investments:backfill_labels[dry_run=true]'
|
||||
#
|
||||
# # Execute the backfill for all investment/crypto accounts
|
||||
# bin/rails 'sure:investments:backfill_labels[dry_run=false]'
|
||||
#
|
||||
# # Backfill for a specific account
|
||||
# bin/rails 'sure:investments:backfill_labels[account_id=8b46387c-5aa4-4a92-963a-4392c10999c9,dry_run=false]'
|
||||
#
|
||||
# # Force re-label already-labeled transactions
|
||||
# bin/rails 'sure:investments:backfill_labels[dry_run=false,force=true]'
|
||||
|
||||
namespace :sure do
|
||||
namespace :investments do
|
||||
desc "Backfill activity labels for existing investment transactions. Args: account_id (optional), dry_run=true, force=false"
|
||||
task :backfill_labels, [ :account_id, :dry_run, :force ] => :environment do |_, args|
|
||||
# Support named args (key=value) - parse all positional args for key=value pairs
|
||||
kv = {}
|
||||
[ args[:account_id], args[:dry_run], args[:force] ].each do |raw|
|
||||
next unless raw.is_a?(String) && raw.include?("=")
|
||||
k, v = raw.split("=", 2)
|
||||
kv[k.to_s] = v
|
||||
end
|
||||
|
||||
# Only use positional args if they don't contain "=" (otherwise they're named args in wrong position)
|
||||
positional_account_id = args[:account_id] unless args[:account_id].to_s.include?("=")
|
||||
positional_dry_run = args[:dry_run] unless args[:dry_run].to_s.include?("=")
|
||||
positional_force = args[:force] unless args[:force].to_s.include?("=")
|
||||
|
||||
account_id = (kv["account_id"] || positional_account_id).presence
|
||||
dry_raw = (kv["dry_run"] || positional_dry_run).to_s.downcase
|
||||
force_raw = (kv["force"] || positional_force).to_s.downcase
|
||||
force = %w[true yes 1].include?(force_raw)
|
||||
|
||||
# Default to dry_run=true unless explicitly disabled
|
||||
dry_run = if dry_raw.blank?
|
||||
true
|
||||
elsif %w[1 true yes y].include?(dry_raw)
|
||||
true
|
||||
elsif %w[0 false no n].include?(dry_raw)
|
||||
false
|
||||
else
|
||||
puts({ ok: false, error: "invalid_argument", message: "dry_run must be one of: true/yes/1 or false/no/0" }.to_json)
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Build account scope
|
||||
accounts = if account_id.present?
|
||||
Account.where(id: account_id)
|
||||
else
|
||||
Account.where(accountable_type: %w[Investment Crypto])
|
||||
end
|
||||
|
||||
if accounts.none?
|
||||
puts({ ok: false, error: "no_accounts", message: "No investment/crypto accounts found" }.to_json)
|
||||
exit 1
|
||||
end
|
||||
|
||||
total_processed = 0
|
||||
total_labeled = 0
|
||||
total_skipped = 0
|
||||
total_errors = 0
|
||||
|
||||
accounts.find_each do |account|
|
||||
# Skip non-investment/crypto accounts if processing all
|
||||
next unless account.investment? || account.crypto?
|
||||
|
||||
acct_processed = 0
|
||||
acct_labeled = 0
|
||||
acct_skipped = 0
|
||||
acct_errors = 0
|
||||
|
||||
# Find transactions (optionally include already-labeled if force=true)
|
||||
entries = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
|
||||
unless force
|
||||
entries = entries.where("transactions.investment_activity_label IS NULL OR transactions.investment_activity_label = ''")
|
||||
end
|
||||
|
||||
entries.find_each do |entry|
|
||||
acct_processed += 1
|
||||
total_processed += 1
|
||||
|
||||
begin
|
||||
transaction = entry.transaction
|
||||
current_label = transaction.investment_activity_label
|
||||
label = InvestmentActivityDetector.infer_label_from_description(entry.name, entry.amount, account)
|
||||
|
||||
# Skip if no label can be inferred
|
||||
if label.blank?
|
||||
acct_skipped += 1
|
||||
total_skipped += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Skip if label unchanged (when force=true)
|
||||
if current_label == label
|
||||
acct_skipped += 1
|
||||
total_skipped += 1
|
||||
next
|
||||
end
|
||||
|
||||
if dry_run
|
||||
if current_label.present?
|
||||
puts " [DRY RUN] Would relabel '#{entry.name}' (#{entry.amount}) from '#{current_label}' to '#{label}'"
|
||||
else
|
||||
puts " [DRY RUN] Would label '#{entry.name}' (#{entry.amount}) as '#{label}'"
|
||||
end
|
||||
else
|
||||
transaction.update!(investment_activity_label: label)
|
||||
if current_label.present?
|
||||
puts " Relabeled '#{entry.name}' (#{entry.amount}) from '#{current_label}' to '#{label}'"
|
||||
else
|
||||
puts " Labeled '#{entry.name}' (#{entry.amount}) as '#{label}'"
|
||||
end
|
||||
end
|
||||
acct_labeled += 1
|
||||
total_labeled += 1
|
||||
rescue => e
|
||||
acct_errors += 1
|
||||
total_errors += 1
|
||||
puts({ error: e.class.name, message: e.message, entry_id: entry.id }.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
puts({ account_id: account.id, account_name: account.name, accountable_type: account.accountable_type, processed: acct_processed, labeled: acct_labeled, skipped: acct_skipped, errors: acct_errors, dry_run: dry_run, force: force }.to_json)
|
||||
end
|
||||
|
||||
puts({ ok: true, total_processed: total_processed, total_labeled: total_labeled, total_skipped: total_skipped, total_errors: total_errors, dry_run: dry_run }.to_json)
|
||||
end
|
||||
|
||||
desc "Clear all investment activity labels (for testing). Args: account_id (required), dry_run=true"
|
||||
task :clear_labels, [ :account_id, :dry_run ] => :environment do |_, args|
|
||||
kv = {}
|
||||
[ args[:account_id], args[:dry_run] ].each do |raw|
|
||||
next unless raw.is_a?(String) && raw.include?("=")
|
||||
k, v = raw.split("=", 2)
|
||||
kv[k.to_s] = v
|
||||
end
|
||||
|
||||
# Only use positional args if they don't contain "="
|
||||
positional_account_id = args[:account_id] unless args[:account_id].to_s.include?("=")
|
||||
positional_dry_run = args[:dry_run] unless args[:dry_run].to_s.include?("=")
|
||||
|
||||
account_id = (kv["account_id"] || positional_account_id).presence
|
||||
dry_raw = (kv["dry_run"] || positional_dry_run).to_s.downcase
|
||||
|
||||
unless account_id.present?
|
||||
puts({ ok: false, error: "usage", message: "Provide account_id" }.to_json)
|
||||
exit 1
|
||||
end
|
||||
|
||||
dry_run = if dry_raw.blank?
|
||||
true
|
||||
elsif %w[1 true yes y].include?(dry_raw)
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
|
||||
account = Account.find(account_id)
|
||||
|
||||
count = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where("transactions.investment_activity_label IS NOT NULL AND transactions.investment_activity_label != ''")
|
||||
.count
|
||||
|
||||
if dry_run
|
||||
puts({ ok: true, message: "Would clear #{count} labels", dry_run: true }.to_json)
|
||||
else
|
||||
Transaction.joins(:entry)
|
||||
.where(entries: { account_id: account_id })
|
||||
.where("investment_activity_label IS NOT NULL AND investment_activity_label != ''")
|
||||
.update_all(investment_activity_label: nil)
|
||||
puts({ ok: true, message: "Cleared #{count} labels", dry_run: false }.to_json)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -285,4 +285,70 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||
assert_equal 5, totals.transactions_count
|
||||
assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150
|
||||
end
|
||||
|
||||
# NEW TESTS: exclude_from_cashflow Feature
|
||||
test "excludes transactions with exclude_from_cashflow flag from totals" do
|
||||
# Create an expense transaction and mark it as excluded from cashflow
|
||||
excluded_entry = create_transaction(account: @checking_account, amount: 250, category: @groceries_category)
|
||||
excluded_entry.update!(exclude_from_cashflow: true)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
|
||||
|
||||
# Should NOT include the excluded transaction
|
||||
assert_equal 4, totals.transactions_count # Only original 4 transactions
|
||||
assert_equal Money.new(1000, @family.currency), totals.income_money
|
||||
assert_equal Money.new(900, @family.currency), totals.expense_money
|
||||
end
|
||||
|
||||
test "excludes income transactions with exclude_from_cashflow flag" do
|
||||
# Create income and mark as excluded from cashflow
|
||||
excluded_income = create_transaction(account: @checking_account, amount: -500, category: @income_category)
|
||||
excluded_income.update!(exclude_from_cashflow: true)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
|
||||
|
||||
# Should NOT include the excluded income
|
||||
assert_equal 4, totals.transactions_count
|
||||
assert_equal Money.new(1000, @family.currency), totals.income_money # Original income only
|
||||
assert_equal Money.new(900, @family.currency), totals.expense_money
|
||||
end
|
||||
|
||||
test "excludes investment_contribution transactions from income statement" do
|
||||
# Create a transfer to investment account (marked as investment_contribution)
|
||||
investment_contribution = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 1000,
|
||||
category: nil,
|
||||
kind: "investment_contribution"
|
||||
)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
|
||||
|
||||
# investment_contribution should be excluded (it's in the exclusion list)
|
||||
assert_equal 4, totals.transactions_count # Only original 4 transactions
|
||||
assert_equal Money.new(1000, @family.currency), totals.income_money
|
||||
assert_equal Money.new(900, @family.currency), totals.expense_money
|
||||
end
|
||||
|
||||
test "exclude_from_cashflow works with median calculations" do
|
||||
# Clear existing transactions
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create expenses: 100, 200, 300
|
||||
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||
excluded_entry = create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||
|
||||
# Exclude the 300 transaction from cashflow
|
||||
excluded_entry.update!(exclude_from_cashflow: true)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
|
||||
# Median should only consider non-excluded transactions (100, 200)
|
||||
# Monthly total = 300, so median = 300.0
|
||||
assert_equal 300.0, income_statement.median_expense(interval: "month")
|
||||
end
|
||||
end
|
||||
|
||||
299
test/models/investment_activity_detector_test.rb
Normal file
299
test/models/investment_activity_detector_test.rb
Normal file
@@ -0,0 +1,299 @@
|
||||
require "test_helper"
|
||||
|
||||
class InvestmentActivityDetectorTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
@investment_account = @family.accounts.create!(
|
||||
name: "Brokerage",
|
||||
balance: 10000,
|
||||
cash_balance: 2000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
@detector = InvestmentActivityDetector.new(@investment_account)
|
||||
end
|
||||
|
||||
test "detects new holding purchase and marks matching transaction" do
|
||||
# Create a transaction that matches a new holding purchase
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 1000,
|
||||
name: "Buy VFIAX"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# Simulate holdings snapshot showing a new holding
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
# No previous snapshot
|
||||
@investment_account.update!(holdings_snapshot_data: nil, holdings_snapshot_at: nil)
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
entry.reload
|
||||
assert entry.exclude_from_cashflow?, "Transaction matching new holding should be excluded from cashflow"
|
||||
end
|
||||
|
||||
test "detects holding sale and marks matching transaction" do
|
||||
# Set up previous holdings
|
||||
previous_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 2000.0, "shares" => 20 }
|
||||
]
|
||||
@investment_account.update!(
|
||||
holdings_snapshot_data: previous_holdings,
|
||||
holdings_snapshot_at: 1.day.ago
|
||||
)
|
||||
|
||||
# Create a transaction for the sale proceeds (negative = inflow)
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: -1000,
|
||||
name: "Sell VFIAX"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# Current holdings show reduced position
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
entry.reload
|
||||
assert entry.exclude_from_cashflow?, "Transaction matching holding sale should be excluded from cashflow"
|
||||
end
|
||||
|
||||
test "respects locked exclude_from_cashflow attribute" do
|
||||
# Create a transaction and lock the attribute
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 1000,
|
||||
name: "Buy VFIAX"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# User explicitly set to NOT exclude (and locked it)
|
||||
entry.update!(exclude_from_cashflow: false)
|
||||
entry.lock_attr!(:exclude_from_cashflow)
|
||||
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
entry.reload
|
||||
assert_not entry.exclude_from_cashflow?, "Locked attribute should not be overwritten"
|
||||
end
|
||||
|
||||
test "updates holdings snapshot after detection" do
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 },
|
||||
{ "symbol" => "IBIT", "cost_basis" => 500.0, "shares" => 5 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [])
|
||||
|
||||
@investment_account.reload
|
||||
# Snapshot is normalized with string values and additional fields
|
||||
snapshot = @investment_account.holdings_snapshot_data
|
||||
assert_equal 2, snapshot.size
|
||||
assert_equal "VFIAX", snapshot[0]["symbol"]
|
||||
assert_equal "1000.0", snapshot[0]["cost_basis"]
|
||||
assert_equal "10.0", snapshot[0]["shares"]
|
||||
assert_equal "IBIT", snapshot[1]["symbol"]
|
||||
assert_not_nil @investment_account.holdings_snapshot_at
|
||||
end
|
||||
|
||||
test "matches transaction by cost_basis amount within tolerance" do
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 1000.005, # Very close - within 0.01 tolerance
|
||||
name: "Investment purchase"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# Holding with cost basis close to transaction amount (within 0.01)
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
entry.reload
|
||||
assert entry.exclude_from_cashflow?, "Should match transaction within tolerance"
|
||||
end
|
||||
|
||||
test "does not mark unrelated transactions" do
|
||||
# Create a regular expense transaction
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 50,
|
||||
name: "Account fee"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# Holdings that don't match
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
entry.reload
|
||||
assert_not entry.exclude_from_cashflow?, "Unrelated transaction should not be excluded"
|
||||
end
|
||||
|
||||
test "works with crypto accounts" do
|
||||
crypto_account = @family.accounts.create!(
|
||||
name: "Crypto Wallet",
|
||||
balance: 5000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.new
|
||||
)
|
||||
detector = InvestmentActivityDetector.new(crypto_account)
|
||||
|
||||
entry = create_transaction(
|
||||
account: crypto_account,
|
||||
amount: 1000,
|
||||
name: "Buy BTC"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
current_holdings = [
|
||||
{ "symbol" => "BTC", "cost_basis" => 1000.0, "shares" => 0.02 }
|
||||
]
|
||||
|
||||
detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
entry.reload
|
||||
assert entry.exclude_from_cashflow?, "Should work with crypto accounts"
|
||||
end
|
||||
|
||||
test "handles empty holdings gracefully" do
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 1000,
|
||||
name: "Some transaction"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# Should not raise, just do nothing
|
||||
assert_nothing_raised do
|
||||
@detector.detect_and_mark_internal_activity([], [ transaction ])
|
||||
end
|
||||
|
||||
entry.reload
|
||||
assert_not entry.exclude_from_cashflow?
|
||||
end
|
||||
|
||||
test "handles nil holdings gracefully" do
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 1000,
|
||||
name: "Some transaction"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
assert_nothing_raised do
|
||||
@detector.detect_and_mark_internal_activity(nil, [ transaction ])
|
||||
end
|
||||
|
||||
entry.reload
|
||||
assert_not entry.exclude_from_cashflow?
|
||||
end
|
||||
|
||||
test "sets Buy label for new holding purchase" do
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: 1000,
|
||||
name: "Some investment"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
transaction.reload
|
||||
assert_equal "Buy", transaction.investment_activity_label
|
||||
end
|
||||
|
||||
test "sets Sell label for holding sale" do
|
||||
previous_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 2000.0, "shares" => 20 }
|
||||
]
|
||||
@investment_account.update!(
|
||||
holdings_snapshot_data: previous_holdings,
|
||||
holdings_snapshot_at: 1.day.ago
|
||||
)
|
||||
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: -1000,
|
||||
name: "VFIAX Sale"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
transaction.reload
|
||||
assert_equal "Sell", transaction.investment_activity_label
|
||||
end
|
||||
|
||||
test "infers Sweep In label from money market description" do
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: -500,
|
||||
name: "VANGUARD FEDERAL MONEY MARKET"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# Call with empty holdings but simulate it being a sweep
|
||||
# This tests the infer_from_description fallback
|
||||
current_holdings = [
|
||||
{ "symbol" => "VMFXX", "cost_basis" => 500.0, "shares" => 500 }
|
||||
]
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
transaction.reload
|
||||
# Should be either "Buy" (from holdings match) or "Sweep In" (from description)
|
||||
assert transaction.investment_activity_label.present?
|
||||
end
|
||||
|
||||
test "infers Dividend label from CASH description" do
|
||||
entry = create_transaction(
|
||||
account: @investment_account,
|
||||
amount: -50,
|
||||
name: "CASH"
|
||||
)
|
||||
transaction = entry.transaction
|
||||
|
||||
# No holdings change, but description-based inference
|
||||
current_holdings = [
|
||||
{ "symbol" => "VFIAX", "cost_basis" => 1000.0, "shares" => 10 }
|
||||
]
|
||||
@investment_account.update!(
|
||||
holdings_snapshot_data: current_holdings,
|
||||
holdings_snapshot_at: 1.day.ago
|
||||
)
|
||||
|
||||
@detector.detect_and_mark_internal_activity(current_holdings, [ transaction ])
|
||||
|
||||
# Since there's no holdings change, no label gets set via holdings match
|
||||
# But if we manually test the infer_from_description method...
|
||||
label = @detector.send(:infer_from_description, entry)
|
||||
assert_equal "Dividend", label
|
||||
end
|
||||
end
|
||||
@@ -18,4 +18,37 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
|
||||
assert_not transaction.pending?
|
||||
end
|
||||
|
||||
test "investment_contribution is a valid kind" do
|
||||
transaction = Transaction.new(kind: "investment_contribution")
|
||||
|
||||
assert_equal "investment_contribution", transaction.kind
|
||||
assert transaction.investment_contribution?
|
||||
end
|
||||
|
||||
test "all transaction kinds are valid" do
|
||||
valid_kinds = %w[standard funds_movement cc_payment loan_payment one_time investment_contribution]
|
||||
|
||||
valid_kinds.each do |kind|
|
||||
transaction = Transaction.new(kind: kind)
|
||||
assert_equal kind, transaction.kind, "#{kind} should be a valid transaction kind"
|
||||
end
|
||||
end
|
||||
|
||||
test "INTERNAL_ACTIVITY_LABELS contains expected labels" do
|
||||
assert_includes Transaction::INTERNAL_ACTIVITY_LABELS, "Buy"
|
||||
assert_includes Transaction::INTERNAL_ACTIVITY_LABELS, "Sell"
|
||||
assert_includes Transaction::INTERNAL_ACTIVITY_LABELS, "Reinvestment"
|
||||
assert_includes Transaction::INTERNAL_ACTIVITY_LABELS, "Exchange"
|
||||
end
|
||||
|
||||
test "ACTIVITY_LABELS contains all valid labels" do
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Buy"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Sell"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Sweep In"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Sweep Out"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Dividend"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Interest"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Fee"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -104,4 +104,24 @@ class TransferTest < ActiveSupport::TestCase
|
||||
Transfer.create!(inflow_transaction: inflow_entry2.transaction, outflow_transaction: outflow_entry.transaction)
|
||||
end
|
||||
end
|
||||
|
||||
test "kind_for_account returns investment_contribution for investment accounts" do
|
||||
assert_equal "investment_contribution", Transfer.kind_for_account(accounts(:investment))
|
||||
end
|
||||
|
||||
test "kind_for_account returns investment_contribution for crypto accounts" do
|
||||
assert_equal "investment_contribution", Transfer.kind_for_account(accounts(:crypto))
|
||||
end
|
||||
|
||||
test "kind_for_account returns loan_payment for loan accounts" do
|
||||
assert_equal "loan_payment", Transfer.kind_for_account(accounts(:loan))
|
||||
end
|
||||
|
||||
test "kind_for_account returns cc_payment for credit card accounts" do
|
||||
assert_equal "cc_payment", Transfer.kind_for_account(accounts(:credit_card))
|
||||
end
|
||||
|
||||
test "kind_for_account returns funds_movement for depository accounts" do
|
||||
assert_equal "funds_movement", Transfer.kind_for_account(accounts(:depository))
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user