Files
sure/app/controllers/transactions_controller.rb
T
5825ae81cf Align uncategorized Transactions filter with dashboard aggregate (fix #2592) (#3293)
* Align uncategorized filter with dashboard aggregate (fix #2592)

The Transactions page's 'Uncategorized' category bucket excluded
Transaction::TRANSFER_KINDS, but the dashboard cashflow widget computes
its Uncategorized figure from IncomeStatement::Totals which excludes
Transaction::BUDGET_EXCLUDED_KINDS.

The two sets disagree on loan_payment and investment_contribution:
those kinds were hidden from the list while their value was still
counted in the widget, so the widget's figure could not be reproduced
from the Transactions page.

This changes the uncategorized exclusion in Transaction::Search#
apply_category_filter to match BUDGET_EXCLUDED_KINDS exactly — the same
set the dashboard aggregate excludes — so the widget and the list
always agree. The type filter (apply_type_filter) is left using
TRANSFER_KINDS, which is its correct semantic for the expense/
income/transfer UI switch.

Regression tests:
- search_test.rb: uncategorized filter lists loan_payment +
  investment_contribution (bites before the fix: asserts inclusion on
  two kinds that were being excluded).
- search_test.rb: funds_movement (a member of BUDGET_EXCLUDED_KINDS) is
  still excluded from uncategorized, guarding against over-broadening.

Fixes https://github.com/we-promise/sure/issues/2592

* docs: add docstrings to Transaction::Search methods (PR #3293)

Add comprehensive docstrings to all public and private methods in the
Transaction::Search class to meet 80%+ coverage requirement:

- Add docstring to initialize method
- Add docstring to transactions_scope
- Add docstring to totals method
- Add docstring to cache_key_base
- Add docstring to apply_active_accounts_filter
- Add docstring to apply_category_filter (method touched in PR)
- Add docstring to apply_type_filter
- Add docstring to apply_merchant_filter
- Add docstring to apply_tag_filter
- Add docstring to apply_status_filter

Addresses CodeRabbit docstring coverage requirement.

* fix: preserve one-time transactions in uncategorized searches (PR #3293)

Address Codex feedback: one-time transactions should remain visible in
uncategorized searches since users can categorize them. The previous
approach using BUDGET_EXCLUDED_KINDS excluded one_time, making them
undiscoverable.

Solution: Use a minimal exclusion set for uncategorized that only
excludes pure transfer-like kinds (funds_movement, cc_payment).
This preserves:
- one_time transactions (user-marked as one-time, still categorizable)
- loan_payment transactions (legitimate uncategorized entries)
- investment_contribution transactions (legitimate uncategorized entries)

While still aligning with dashboard by excluding inter-account transfers.

Fixes https://github.com/we-promise/sure/issues/2592
Addresses PR #3293 feedback

* fix: bump totals cache key to avoid stale post-deploy mismatch (#2592)

CodeRabbit flagged a moderate merge risk: Transaction::Search#totals is
cached under a key that only reflects filter *parameters* and data
changes (entries_cache_version), not application code changes. Since
this PR changes which kinds count as "uncategorized," a totals entry
cached before deploy would keep being served after deploy (same
cache_key_base, same family, same filters, no new entries yet),
disagreeing with the Transactions page list -- which reads
transactions_scope directly and isn't cached -- until that family's
entries_cache_version next changes.

Bumping the cache key prefix from v2 to v3 invalidates every existing
totals cache entry on deploy, so the first read after release recomputes
under the new logic instead of serving stale figures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw

* fix(transactions): align uncategorized filter with dashboard exclusions

- Use Transaction::BUDGET_EXCLUDED_KINDS for consistency with dashboard
- Exclude one_time from uncategorized filter to match dashboard behavior
- Update comment to clarify alignment with dashboard uncategorized totals

Fixes disagreement between code comment, test comment, and dashboard behavior.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw

* fix(transactions): share one uncategorized-kind list across all three surfaces

Resolves the two open review threads on #3293, which were the same finding
from opposite sides: the filter excluded BUDGET_EXCLUDED_KINDS, which also
drops one_time.

one_time is documented as "a one-time expense/income, excluded from budget
analytics" -- it is not a transfer, it is still categorizable, and excluding
it made an uncategorized one-time transaction undiscoverable through its
actual category state.

Reviewing that also surfaced a defect neither thread caught. Aligning only
Transaction::Search left Entry.uncategorized_transactions on TRANSFER_KINDS,
so the Transactions filter and the badge count / Quick Categorize wizard
disagreed on three of six kinds:

  kind                      filter      wizard
  loan_payment              included    EXCLUDED
  one_time                  EXCLUDED    included
  investment_contribution   included    EXCLUDED

That leaves #2592's actual complaint standing: the dashboard counts
uncategorized loan_payment / investment_contribution, but the wizard still
would not offer them for categorization.

Introduce Transaction::UNCATEGORIZED_EXCLUDED_KINDS (funds_movement,
cc_payment) -- the kinds that have nothing to categorize because they are
paired legs of a Transfer -- and read it from both surfaces. "Has no
category" and "counts toward the budget" are different questions; only the
former decides this list.

Bump the uncategorized badge cache key to v4, since the count's meaning
changes and the old key would otherwise survive deploy.

The dashboard still excludes one_time from budget totals by design; that
difference is intentional and now documented rather than papered over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-09-08 09:15:40 +02:00

813 lines
30 KiB
Ruby

class TransactionsController < ApplicationController
include EntryableResource
before_action :set_entry_for_unlock, only: :unlock
before_action :set_entry_for_tags, only: :update_tags
before_action :store_params!, only: :index
helper_method :new_transaction_idempotency_key
def show
super
assign_mark_recurring_state
end
def new
prefill_params_from_duplicate!
super
apply_duplicate_attributes!
set_new_transaction_form_options
end
def index
@q = search_params
@accessible_account_ids = Current.user.accessible_accounts.pluck(:id)
@search = Transaction::Search.new(Current.family, filters: @q, accessible_account_ids: @accessible_account_ids)
base_scope = @search.transactions_scope
.reverse_chronological
.includes(
{ entry: :account },
:category, :merchant, :tags,
# Union of #2643 counterpart UI + Skylight category-menu N+1:
# - outflow rows need inflow_transaction (to_account) for both
# counterpart display and Transfer#categorizable?/#payment?
# - inflow rows need outflow_transaction (from_account) for
# counterpart display, and inflow_transaction (to_account)
# for the category menu on the same row
{
transfer_as_outflow: {
inflow_transaction: { entry: :account }
}
},
{
transfer_as_inflow: {
inflow_transaction: { entry: :account },
outflow_transaction: { entry: :account }
}
}
)
@pagy, @transactions = pagy(base_scope, limit: safe_per_page(stored_params["per_page"]))
Transaction::ActivitySecurityPreloader.new(@transactions).preload
# Preload split parent data
entry_ids = @transactions.map { |t| t.entry.id }
# Load split parent entries for grouped display (only when grouping is enabled)
@split_parents = if Current.user.show_split_grouped?
split_parent_ids = @transactions.filter_map { |t| t.entry.parent_entry_id }.uniq
if split_parent_ids.any?
Entry.where(id: split_parent_ids)
.includes(:account, entryable: [ :category, :merchant ])
.index_by(&:id)
else
{}
end
else
{}
end
# Preload which entries on this page are split parents (have children) to avoid N+1
@split_parent_entry_ids = if entry_ids.any?
Entry.where(parent_entry_id: entry_ids).distinct.pluck(:parent_entry_id).to_set
else
Set.new
end
@uncategorized_count = Rails.cache.fetch(uncategorized_count_cache_key) do
Current.accessible_entries.uncategorized_transactions.count
end
# Load projected recurring transactions for next 10 days
@projected_recurring = Rails.cache.fetch(projected_recurring_cache_key, expires_in: 1.day) do
Current.family.recurring_transactions
.accessible_by(Current.user)
.active
.where("next_expected_date <= ? AND next_expected_date >= ?",
10.days.from_now.to_date,
Date.current)
.includes(:merchant)
.to_a
end
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.transactions"), nil ] ]
end
def clear_filter
updated_params = {
"q" => search_params,
"page" => params[:page],
"per_page" => params[:per_page]
}
q_params = updated_params["q"] || {}
param_key = params[:param_key]
param_value = params[:param_value]
if q_params[param_key].is_a?(Array)
q_params[param_key].delete(param_value)
q_params.delete(param_key) if q_params[param_key].empty?
else
q_params.delete(param_key)
end
updated_params["q"] = q_params.presence
# Add flag to indicate filters were explicitly cleared
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
Current.session.update!(prev_transaction_page_params: updated_params)
redirect_to transactions_path(updated_params)
end
def create
account = Current.user.accessible_accounts.find_by(id: params.dig(:entry, :account_id))
if account.nil?
@entry = Current.family.entries.new(entry_params)
@entry.valid?
set_new_transaction_form_options
render :new, status: :unprocessable_entity
return
end
return unless require_account_permission!(account)
idempotency_key = submitted_idempotency_key
# Sequential double-submit guard: the form was already submitted
# successfully once (double-click, browser retry, user reopening the
# dialog after a slow response) and the first request already committed
# by the time this one runs. Treat it as a success instead of creating a
# second, identical entry.
if idempotency_key && (existing_entry = find_duplicate_manual_entry(account, idempotency_key))
respond_with_created_entry(existing_entry)
return
end
@entry = account.entries.new(entry_params_with_idempotency_key(idempotency_key))
if @entry.save
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.mark_user_modified!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
respond_with_created_entry(@entry)
else
set_new_transaction_form_options
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
# Concurrent-request backstop: two near-simultaneous submissions both
# passed the pre-check above (neither saw the other's row yet) and both
# reached #save. The partial unique index on
# entries(account_id, idempotency_key) lets exactly one INSERT win;
# this rescues the loser and redirects it to the winning entry instead of
# creating a duplicate or surfacing a 500 to the user.
existing_entry = idempotency_key && find_duplicate_manual_entry(account, idempotency_key)
raise unless existing_entry
respond_with_created_entry(existing_entry)
end
def update
if @entry.update(permitted_entry_params)
transaction = @entry.transaction
transaction.record_category_usage!
if needs_rule_notification?(transaction)
flash[:cta] = {
type: "category_rule",
category_id: transaction.category_id,
category_name: transaction.category.name
}
end
@entry.lock_saved_attributes!
@entry.mark_user_modified!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
@entry.sync_account_later
notes_changed = @entry.saved_change_to_notes?
# Reload to ensure fresh state for turbo stream rendering
@entry.reload
assign_mark_recurring_state
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t(".updated") }
format.turbo_stream do
in_split_group = helpers.in_split_group?(@entry, params[:grouped])
render turbo_stream: [
turbo_stream.replace(
dom_id(@entry, :header),
partial: "transactions/header",
locals: { entry: @entry }
),
turbo_stream.replace(
dom_id(@entry, :protection),
partial: "entries/protection_indicator",
locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) }
),
(turbo_stream.replace(
dom_id(@entry, :notes),
partial: "transactions/notes",
locals: { entry: @entry, can_annotate: can_annotate_entry? }
) if params[:entry]&.key?(:notes) && notes_changed),
(turbo_stream.replace(
dom_id(@entry, :mark_recurring),
partial: "transactions/mark_recurring",
locals: { entry: @entry }
) if can_edit_entry? && !@entry.split_child?),
turbo_stream.replace(
dom_id(@entry),
partial: "entries/entry",
locals: { entry: @entry, in_split_group: in_split_group }
),
*flash_notification_stream_items
].compact
end
end
else
assign_mark_recurring_state
render :show, status: :unprocessable_entity
end
end
def update_tags
return unless require_account_permission!(@entry.account, :annotate, redirect_path: transaction_path(@entry))
tag_ids = Current.family.tags.where(id: tag_ids_param).pluck(:id)
@entry.transaction.tag_ids = tag_ids
@entry.lock_saved_attributes!
@entry.mark_user_modified!
@entry.transaction.lock_attr!(:tag_ids)
@entry.sync_account_later
render json: { tag_ids: @entry.transaction.tag_ids }
end
def merge_duplicate
transaction = accessible_transactions.includes(entry: :account).find(params[:id])
return unless require_account_permission!(transaction.entry.account)
if transaction.merge_with_duplicate!
flash[:notice] = t("transactions.merge_duplicate.success")
else
flash[:alert] = t("transactions.merge_duplicate.failure")
end
redirect_to transactions_path
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid,
ActiveRecord::RecordNotDestroyed, ActiveRecord::Deadlocked,
ActiveRecord::LockWaitTimeout => e
Rails.logger.error("Failed to merge duplicate transaction #{params[:id]}: #{e.message}")
flash[:alert] = t("transactions.merge_duplicate.failure")
redirect_to transactions_path
end
def dismiss_duplicate
transaction = accessible_transactions.includes(entry: :account).find(params[:id])
return unless require_account_permission!(transaction.entry.account)
if transaction.dismiss_duplicate_suggestion!
flash[:notice] = t("transactions.dismiss_duplicate.success")
else
flash[:alert] = t("transactions.dismiss_duplicate.failure")
end
redirect_back_or_to transactions_path
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("Failed to dismiss duplicate suggestion for transaction #{params[:id]}: #{e.message}")
flash[:alert] = t("transactions.dismiss_duplicate.failure")
redirect_back_or_to transactions_path
end
def convert_to_trade
@transaction = accessible_transactions.includes(entry: :account).find(params[:id])
@entry = @transaction.entry
return unless require_account_permission!(@entry.account)
unless @entry.account.investment?
flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account")
redirect_back_or_to transactions_path
return
end
render :convert_to_trade
end
def create_trade_from_transaction
@transaction = accessible_transactions.includes(entry: :account).find(params[:id])
@entry = @transaction.entry
return unless require_account_permission!(@entry.account)
# Pre-transaction validations
unless @entry.account.investment?
flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account")
redirect_back_or_to transactions_path
return
end
if @entry.excluded?
flash[:alert] = t("transactions.convert_to_trade.errors.already_converted")
redirect_back_or_to transactions_path
return
end
# Resolve security before transaction
security = resolve_security_for_conversion
return if performed? # Early exit if redirect already happened
# Validate and calculate qty/price before transaction
qty, price = calculate_qty_and_price
return if performed? # Early exit if redirect already happened
activity_label = params[:investment_activity_label].presence
# Infer sell from amount sign: negative amount = money coming in = sell
is_sell = activity_label == "Sell" || (activity_label.blank? && @entry.amount < 0)
ActiveRecord::Base.transaction do
# For trades: positive qty = buy (money out), negative qty = sell (money in)
signed_qty = is_sell ? -qty : qty
trade_amount = qty * price
# Sells bring money in (negative amount), Buys take money out (positive amount)
signed_amount = is_sell ? -trade_amount : trade_amount
# Default activity label if not provided
activity_label ||= is_sell ? "Sell" : "Buy"
# Create trade entry with note about conversion
conversion_note = t("transactions.convert_to_trade.conversion_note",
original_name: @entry.name,
original_date: I18n.l(@entry.date, format: :long))
new_entry = @entry.account.entries.create!(
name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker),
date: @entry.date,
amount: signed_amount,
currency: @entry.currency,
notes: conversion_note,
entryable: Trade.new(
security: security,
qty: signed_qty,
price: price,
currency: @entry.currency,
investment_activity_label: activity_label
)
)
# Mark the new trade as user-modified to protect from sync
new_entry.lock_saved_attributes!
new_entry.mark_user_modified!
# Mark original transaction as excluded (soft delete)
@entry.update!(excluded: true)
end
flash[:notice] = t("transactions.convert_to_trade.success")
redirect_to account_path(@entry.account), status: :see_other
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
flash[:alert] = t("transactions.convert_to_trade.errors.conversion_failed", error: e.message)
redirect_back_or_to transactions_path, status: :see_other
rescue StandardError => e
flash[:alert] = t("transactions.convert_to_trade.errors.unexpected_error", error: e.message)
redirect_back_or_to transactions_path, status: :see_other
end
def unlock
return unless require_account_permission!(@entry.account)
@entry.unlock_for_sync!
flash[:notice] = t("entries.unlock.success")
redirect_back_or_to transactions_path
end
def mark_as_recurring
transaction = accessible_transactions.includes(entry: :account).find(params[:id])
return unless require_account_permission!(transaction.entry.account)
# Check if a recurring transaction already exists for this pattern.
# The UI disables the button ahead of time using the same lookup, but this
# guard remains as the authoritative check (e.g. stale page, direct POST).
existing = transaction.existing_manual_recurring_transaction
if existing
flash[:alert] = t("recurring_transactions.already_exists")
redirect_back_or_to transactions_path
return
end
begin
recurring_transaction = RecurringTransaction.create_from_transaction(transaction)
respond_to do |format|
format.html do
flash[:notice] = t("recurring_transactions.marked_as_recurring")
redirect_back_or_to transactions_path
end
end
rescue ActiveRecord::RecordInvalid => e
respond_to do |format|
format.html do
flash[:alert] = t("recurring_transactions.creation_failed")
redirect_back_or_to transactions_path
end
end
rescue ActiveRecord::RecordNotUnique
# Another request created the same (account, name/merchant, amount,
# currency) pattern between the check above and this create — the DB
# unique index is the authoritative backstop for that race.
respond_to do |format|
format.html do
flash[:alert] = t("recurring_transactions.already_exists")
redirect_back_or_to transactions_path
end
end
rescue StandardError => e
respond_to do |format|
format.html do
flash[:alert] = t("recurring_transactions.unexpected_error")
redirect_back_or_to transactions_path
end
end
end
end
def exchange_rate
account = Current.family.accounts.find(params[:account_id])
currency_from = params[:currency]
date = params[:date]&.to_date || Date.current
if account.currency == currency_from
render json: { same_currency: true, rate: 1.0 }
else
rate_obj = ExchangeRate.find_or_fetch_rate(
from: currency_from,
to: account.currency,
date: date
)
if rate_obj.nil?
return render json: { error: "Exchange rate not found" }, status: :not_found
end
rate_value = rate_obj.is_a?(Numeric) ? rate_obj : rate_obj.rate
render json: { rate: rate_value.to_f, account_currency: account.currency }
end
end
private
# Scoped by user (not just family) because Current.accessible_entries is
# user-scoped for family sharing (see Current#accessible_entries).
#
# Includes Family#accounts_status_version because `uncategorized_transactions`
# filters on account status (draft/active), and toggling an account's
# active status (AccountsController#toggle_active) doesn't touch `entries`
# or `AccountShare`, so it wouldn't otherwise bust this cache.
def uncategorized_count_cache_key
"transactions_uncategorized_count/v4/#{Current.family.id}/#{Current.user.id}/" \
"#{Current.family.entries_version}/#{Current.family.accounts_status_version}/#{Current.account_share_version}"
end
# Scoped additionally by Date.current since the "next 10 days" window is
# date-dependent and would otherwise return a stale window on a cache hit
# from an earlier day.
#
# Includes Family#recurring_transaction_merchants_version because the
# cached records are preloaded with :merchant and rendered with its
# name/logo, but editing a FamilyMerchant or a shared ProviderMerchant
# doesn't touch `recurring_transactions`.
def projected_recurring_cache_key
"transactions_projected_recurring/v5/#{Current.family.id}/#{Current.user.id}/#{Date.current}/" \
"#{Current.family.recurring_transactions_version}/#{Current.family.accounts_status_version}/" \
"#{Current.family.recurring_transaction_merchants_version}/#{Current.account_share_version}"
end
# The "Mark as Recurring" block is only ever rendered under these same
# conditions (see transactions/show.html.erb), so skip the extra query
# entirely when it won't be used — this runs on every show/failed-update
# render, including read-only viewers and split-child transactions.
def assign_mark_recurring_state
return unless can_edit_entry? && !@entry.split_child?
existing = @entry.transaction.existing_manual_recurring_transaction
@mark_recurring_href = mark_as_recurring_transaction_path(@entry.transaction)
@mark_recurring_subtitle_class = existing ? "text-subdued" : "text-secondary"
@mark_recurring_subtitle = existing ? t("recurring_transactions.already_exists") : t("transactions.show.mark_recurring_subtitle")
@mark_recurring_disabled = existing.present?
@mark_recurring_title = existing ? t("recurring_transactions.already_exists") : nil
@mark_recurring_button_class = existing ? "disabled:opacity-50" : nil
end
def accessible_transactions
Current.family.transactions
.joins(entry: :account)
.merge(Account.accessible_by(Current.user))
end
def duplicate_source
return @duplicate_source if defined?(@duplicate_source)
@duplicate_source = if params[:duplicate_entry_id].present?
source = Current.family.entries.joins(:account).merge(Account.accessible_by(Current.user)).find_by(id: params[:duplicate_entry_id])
source if source&.transaction?
end
end
def prefill_params_from_duplicate!
return unless duplicate_source
params[:nature] ||= duplicate_source.amount.negative? ? "inflow" : "outflow"
params[:account_id] ||= duplicate_source.account_id.to_s
end
def apply_duplicate_attributes!
return unless duplicate_source
@entry.assign_attributes(
name: duplicate_source.name,
amount: duplicate_source.amount.abs,
currency: duplicate_source.currency,
notes: duplicate_source.notes
)
@entry.entryable.assign_attributes(
category_id: duplicate_source.entryable.category_id,
merchant_id: duplicate_source.entryable.merchant_id
)
@entry.entryable.tag_ids = duplicate_source.entryable.tag_ids
end
def set_entry_for_unlock
transaction = accessible_transactions.find(params[:id])
@entry = transaction.entry
end
def needs_rule_notification?(transaction)
return false if Current.user.rule_prompts_disabled
if Current.user.rule_prompt_dismissed_at.present?
time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at
return false if time_since_last_rule_prompt < 1.day
end
transaction.saved_change_to_category_id? && transaction.category_id.present? &&
transaction.eligible_for_category_rule?
end
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, :investment_activity_label, :exchange_rate, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)
entry_params.delete(:amount) if entry_params[:amount].blank?
entry_params.delete(:date) if entry_params[:date].blank?
if nature.present? && entry_params[:amount].present?
signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
entry_params = entry_params.merge(amount: signed_amount)
end
entry_params
end
def entry_params_with_idempotency_key(idempotency_key)
return entry_params unless idempotency_key
# A dedicated column, deliberately not external_id/source: those are
# provider-linkage fields (Entry#linked? = external_id.present?), and
# reusing them here would make a manual entry look provider-synced -
# disabling its date/nature/amount/currency fields in the editor, and
# hiding it from future provider dedup matching.
entry_params.merge(idempotency_key: idempotency_key)
end
def find_duplicate_manual_entry(account, idempotency_key)
account.entries.find_by(idempotency_key: idempotency_key)
end
# The hidden "entry[idempotency_key]" field is rendered fresh (a random
# UUID) every time the new-transaction form loads, and echoed back
# unchanged by the browser on submit. It's never trusted for anything but
# de-duplication scoped to the current user's own account (see #create),
# so we only require that it looks like a UUID we could have generated -
# anything else (missing field, tampered value, non-string type from a
# malformed request) just disables the idempotency check for that
# request rather than being treated as an error.
UUID_FORMAT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
private_constant :UUID_FORMAT
def submitted_idempotency_key
key = params.dig(:entry, :idempotency_key)
key if key.is_a?(String) && key.match?(UUID_FORMAT)
end
def new_transaction_idempotency_key
@new_transaction_idempotency_key ||= submitted_idempotency_key || SecureRandom.uuid
end
def respond_with_created_entry(entry)
flash[:notice] = t(".created")
respond_to do |format|
format.html { redirect_back_or_to account_path(entry.account) }
format.turbo_stream { stream_redirect_back_or_to(account_path(entry.account)) }
end
end
def tag_ids_param
Array(params[:tag_ids]).reject(&:blank?)
end
def set_entry_for_tags
set_entry
end
def set_new_transaction_form_options
accessible_accounts_scope = accessible_accounts
@account_currencies = accessible_accounts_scope.pluck(:id, :currency).to_h
@manual_accounts = accessible_accounts_scope
.manual
.active
.alphabetically
.includes(:account_providers, logo_attachment: :blob)
.to_a
@categories = Current.family.categories.alphabetically_by_hierarchy.to_a
@merchants = Current.family.available_merchants_for(Current.user).alphabetically.to_a
@tags = Current.family.tags.alphabetically.to_a
end
# Filters entry_params based on the user's permission on the account.
# read_write users can only annotate (category, tags, notes, merchant).
# read_only users cannot update anything.
def permitted_entry_params
case entry_permission
when :owner, :full_control
entry_params
when :read_write
# Annotate only: category, tags, merchant, notes
ep = entry_params.slice(:notes)
if entry_params[:entryable_attributes].present?
ep[:entryable_attributes] = entry_params[:entryable_attributes].slice(:id, :category_id, :merchant_id, :tag_ids)
end
ep
else
{} # read_only — no edits allowed
end
end
def search_params
cleaned_params = params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, :active_accounts_only,
accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: [], status: []
)
.to_h
.compact_blank
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
cleaned_params
end
def store_params!
if should_restore_params?
params_to_restore = {}
params_to_restore[:q] = stored_params["q"].presence || {}
params_to_restore[:page] = stored_params["page"].presence || 1
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
redirect_to transactions_path(params_to_restore)
else
Current.session.update!(
prev_transaction_page_params: {
q: search_params,
page: params[:page],
per_page: params[:per_page].presence || stored_params["per_page"]
}
)
end
end
def should_restore_params?
request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?)
end
def stored_params
Current.session.prev_transaction_page_params
end
# Helper methods for convert_to_trade
def resolve_security_for_conversion
user_country = Current.family.country
if params[:security_id] == "__custom__"
# User selected "Enter custom ticker" - check for combobox selection or manual entry
if params[:ticker].present?
# Combobox selection: format is "SYMBOL|EXCHANGE|PROVIDER"
parsed = Security.parse_combobox_id(params[:ticker])
if parsed[:ticker].blank?
flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker")
redirect_back_or_to transactions_path
return nil
end
Security::Resolver.new(
parsed[:ticker].strip,
exchange_operating_mic: parsed[:exchange_operating_mic] || params[:exchange_operating_mic].presence,
country_code: user_country,
price_provider: parsed[:price_provider]
).resolve
elsif params[:custom_ticker].present?
# Manual entry from combobox's name_when_new or fallback text field
Security::Resolver.new(
params[:custom_ticker].strip,
exchange_operating_mic: params[:exchange_operating_mic].presence,
country_code: user_country
).resolve
else
flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker")
redirect_back_or_to transactions_path
return nil
end
elsif params[:security_id].present?
found = Security.find_by(id: params[:security_id])
unless found
flash[:alert] = t("transactions.convert_to_trade.errors.security_not_found")
redirect_back_or_to transactions_path
return nil
end
found
elsif params[:ticker].present?
# Direct combobox (no existing holdings) - format is "SYMBOL|EXCHANGE|PROVIDER"
parsed = Security.parse_combobox_id(params[:ticker])
if parsed[:ticker].blank?
flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker")
redirect_back_or_to transactions_path
return nil
end
Security::Resolver.new(
parsed[:ticker].strip,
exchange_operating_mic: parsed[:exchange_operating_mic] || params[:exchange_operating_mic].presence,
country_code: user_country,
price_provider: parsed[:price_provider]
).resolve
elsif params[:custom_ticker].present?
# Manual entry from combobox's name_when_new (no existing holdings path)
Security::Resolver.new(
params[:custom_ticker].strip,
exchange_operating_mic: params[:exchange_operating_mic].presence,
country_code: user_country
).resolve
end.tap do |security|
if security.nil? && !performed?
flash[:alert] = t("transactions.convert_to_trade.errors.select_security")
redirect_back_or_to transactions_path
end
end
end
def calculate_qty_and_price
amount = @entry.amount.abs
qty = params[:qty].present? ? params[:qty].to_d.abs : nil
price = params[:price].present? ? params[:price].to_d : nil
if qty.nil? && price.nil?
flash[:alert] = t("transactions.convert_to_trade.errors.enter_qty_or_price")
redirect_back_or_to transactions_path, status: :see_other
return [ nil, nil ]
elsif qty.nil? && price.present? && price > 0
qty = (amount / price).round(6)
elsif price.nil? && qty.present? && qty > 0
price = (amount / qty).round(4)
end
if qty.nil? || qty <= 0 || price.nil? || price <= 0
flash[:alert] = t("transactions.convert_to_trade.errors.invalid_qty_or_price")
redirect_back_or_to transactions_path, status: :see_other
return [ nil, nil ]
end
[ qty, price ]
end
end