mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 19:14:11 +00:00
Merge pull request #608 from luckyPipewrench/investment-activity
Investment activity labels and classification
This commit is contained in:
@@ -36,7 +36,7 @@ class AccountsController < ApplicationController
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@tab = params[:tab]
|
||||
@q = params.fetch(:q, {}).permit(:search, status: [])
|
||||
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ 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: [] } ]
|
||||
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,7 +115,16 @@ 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.assign_attributes(investment_activity_label: investment_activity_label)
|
||||
end
|
||||
end
|
||||
|
||||
entry.save!
|
||||
entry.transaction.save! if entry.transaction.changed?
|
||||
|
||||
# AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim)
|
||||
# This handles tip adjustments where auto-matching is too risky
|
||||
|
||||
@@ -47,7 +47,7 @@ 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 (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
|
||||
@@ -44,7 +44,7 @@ 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 (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
|
||||
@@ -69,9 +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 a.family_id = :family_id
|
||||
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;
|
||||
SQL
|
||||
@@ -95,9 +95,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 a.family_id = :family_id
|
||||
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
|
||||
SQL
|
||||
@@ -126,7 +126,7 @@ class IncomeStatement::Totals
|
||||
WHERE a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
AND ae.excluded = false
|
||||
AND ae.date BETWEEN :start_date AND :end_date
|
||||
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
|
||||
end
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
class PlaidAccount::Investments::TransactionsProcessor
|
||||
SecurityNotFoundError = Class.new(StandardError)
|
||||
|
||||
# Map Plaid investment transaction types to activity labels
|
||||
# All values must be valid Transaction::ACTIVITY_LABELS
|
||||
PLAID_TYPE_TO_LABEL = {
|
||||
"buy" => "Buy",
|
||||
"sell" => "Sell",
|
||||
"cancel" => "Other",
|
||||
"cash" => "Other",
|
||||
"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 +86,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] || "Other"
|
||||
end
|
||||
|
||||
def transactions
|
||||
plaid_account.raw_investments_payload["transactions"] || []
|
||||
end
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor
|
||||
def label
|
||||
"Set investment activity label"
|
||||
end
|
||||
|
||||
def type
|
||||
"select"
|
||||
end
|
||||
|
||||
def options
|
||||
Transaction::ACTIVITY_LABELS.map { |l| [ l, l ] }
|
||||
end
|
||||
|
||||
def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil)
|
||||
return 0 unless Transaction::ACTIVITY_LABELS.include?(value)
|
||||
|
||||
scope = transaction_scope
|
||||
|
||||
unless ignore_attribute_locks
|
||||
scope = scope.enrichable(:investment_activity_label)
|
||||
end
|
||||
|
||||
count_modified_resources(scope) do |txn|
|
||||
txn.enrich_attribute(
|
||||
:investment_activity_label,
|
||||
value,
|
||||
source: "rule"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -20,6 +20,7 @@ class Rule::Registry::TransactionResource < Rule::Registry
|
||||
Rule::ActionExecutor::SetTransactionTags.new(rule),
|
||||
Rule::ActionExecutor::SetTransactionMerchant.new(rule),
|
||||
Rule::ActionExecutor::SetTransactionName.new(rule),
|
||||
Rule::ActionExecutor::SetInvestmentActivityLabel.new(rule),
|
||||
Rule::ActionExecutor::ExcludeTransaction.new(rule)
|
||||
]
|
||||
|
||||
|
||||
@@ -16,9 +16,16 @@ 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, excluded from budget analytics
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# 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, -> {
|
||||
|
||||
@@ -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') 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') 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,13 @@
|
||||
</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") %>">
|
||||
<%= t("transactions.activity_labels.#{transaction.investment_activity_label.parameterize(separator: '_')}") %>
|
||||
</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,33 @@
|
||||
<% 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| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), 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),
|
||||
@@ -211,8 +238,8 @@
|
||||
<%= 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">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
|
||||
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
|
||||
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
|
||||
<p class="text-secondary"><%= t(".one_time_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= ef.toggle :kind, {
|
||||
|
||||
@@ -31,26 +31,47 @@ en:
|
||||
balances, and cannot be undone.
|
||||
delete_title: Delete transaction
|
||||
details: Details
|
||||
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
|
||||
merchant_label: Merchant
|
||||
name_label: Name
|
||||
nature: Type
|
||||
none: "(none)"
|
||||
note_label: Notes
|
||||
note_placeholder: Enter a note
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
tags_label: Tags
|
||||
uncategorized: "(uncategorized)"
|
||||
potential_duplicate_title: Possible duplicate detected
|
||||
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
|
||||
exclude: Exclude
|
||||
exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
|
||||
activity_type: Activity Type
|
||||
activity_type_description: Type of investment activity (Buy, Sell, Dividend, etc.). Auto-detected or set manually.
|
||||
one_time_title: One-time %{type}
|
||||
one_time_description: One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
|
||||
activity_labels:
|
||||
buy: Buy
|
||||
sell: Sell
|
||||
sweep_in: Sweep In
|
||||
sweep_out: Sweep Out
|
||||
dividend: Dividend
|
||||
reinvestment: Reinvestment
|
||||
interest: Interest
|
||||
fee: Fee
|
||||
transfer: Transfer
|
||||
contribution: Contribution
|
||||
withdrawal: Withdrawal
|
||||
exchange: Exchange
|
||||
other: Other
|
||||
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
|
||||
merchant_label: Merchant
|
||||
name_label: Name
|
||||
nature: Type
|
||||
none: "(none)"
|
||||
note_label: Notes
|
||||
note_placeholder: Enter a note
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
tags_label: Tags
|
||||
uncategorized: "(uncategorized)"
|
||||
potential_duplicate_title: Possible duplicate detected
|
||||
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
|
||||
transaction:
|
||||
pending: Pending
|
||||
pending_tooltip: Pending transaction — may change when posted
|
||||
activity_type_tooltip: Investment activity type
|
||||
possible_duplicate: Duplicate?
|
||||
potential_duplicate_tooltip: This may be a duplicate of another transaction
|
||||
review_recommended: Review
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddInvestmentCashflowSupport < ActiveRecord::Migration[7.2]
|
||||
# No-op: exclude_from_cashflow was consolidated into the existing 'excluded' toggle
|
||||
def change
|
||||
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
|
||||
2
db/schema.rb
generated
2
db/schema.rb
generated
@@ -342,14 +342,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.string "external_id"
|
||||
t.string "source"
|
||||
t.boolean "exclude_from_cashflow", default: false, null: false
|
||||
t.index "lower((name)::text)", name: "index_entries_on_lower_name"
|
||||
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
|
||||
t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))"
|
||||
t.index ["account_id"], name: "index_entries_on_account_id"
|
||||
t.index ["date"], name: "index_entries_on_date"
|
||||
t.index ["entryable_type"], name: "index_entries_on_entryable_type"
|
||||
t.index ["exclude_from_cashflow"], name: "index_entries_on_exclude_from_cashflow"
|
||||
t.index ["import_id"], name: "index_entries_on_import_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -285,4 +285,22 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||
assert_equal 5, totals.transactions_count
|
||||
assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150
|
||||
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
|
||||
end
|
||||
|
||||
@@ -100,4 +100,36 @@ class Rule::ActionTest < ActiveSupport::TestCase
|
||||
assert_equal new_name, transaction.reload.entry.name
|
||||
end
|
||||
end
|
||||
|
||||
test "set_investment_activity_label" do
|
||||
# Does not modify transactions that are locked (user edited them)
|
||||
@txn1.lock_attr!(:investment_activity_label)
|
||||
|
||||
action = Rule::Action.new(
|
||||
rule: @transaction_rule,
|
||||
action_type: "set_investment_activity_label",
|
||||
value: "Dividend"
|
||||
)
|
||||
|
||||
action.apply(@rule_scope)
|
||||
|
||||
assert_nil @txn1.reload.investment_activity_label
|
||||
|
||||
[ @txn2, @txn3 ].each do |transaction|
|
||||
assert_equal "Dividend", transaction.reload.investment_activity_label
|
||||
end
|
||||
end
|
||||
|
||||
test "set_investment_activity_label ignores invalid values" do
|
||||
action = Rule::Action.new(
|
||||
rule: @transaction_rule,
|
||||
action_type: "set_investment_activity_label",
|
||||
value: "InvalidLabel"
|
||||
)
|
||||
|
||||
result = action.apply(@rule_scope)
|
||||
|
||||
assert_equal 0, result
|
||||
assert_nil @txn1.reload.investment_activity_label
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,36 @@ 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 "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, "Reinvestment"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Interest"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Fee"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Transfer"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Contribution"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Withdrawal"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Exchange"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Other"
|
||||
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