mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* 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>
429 lines
17 KiB
Ruby
429 lines
17 KiB
Ruby
class Transaction < ApplicationRecord
|
|
include Entryable, Transferable, Ruleable, Splittable
|
|
|
|
belongs_to :category, optional: true
|
|
belongs_to :merchant, optional: true
|
|
belongs_to :transfer, optional: true
|
|
|
|
has_many :taggings, as: :taggable, dependent: :destroy
|
|
has_many :tags, through: :taggings
|
|
|
|
# File attachments (receipts, invoices, etc.) using Active Storage
|
|
# Supports images (JPEG, PNG, GIF, WebP) and PDFs up to 10MB each
|
|
# Maximum 10 attachments per transaction, family-scoped access
|
|
has_many_attached :attachments do |attachable|
|
|
attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ]
|
|
end
|
|
|
|
# Attachment validation constants
|
|
MAX_ATTACHMENTS_PER_TRANSACTION = 10
|
|
MAX_ATTACHMENT_SIZE = 10.megabytes
|
|
ALLOWED_CONTENT_TYPES = %w[
|
|
image/jpeg image/jpg image/png image/gif image/webp
|
|
application/pdf
|
|
].freeze
|
|
|
|
validate :validate_attachments, if: -> { attachments.attached? }
|
|
|
|
accepts_nested_attributes_for :taggings, allow_destroy: true
|
|
|
|
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
|
|
|
|
# Accessors for exchange_rate stored in extra jsonb field
|
|
def exchange_rate
|
|
extra&.dig("exchange_rate")
|
|
end
|
|
|
|
def exchange_rate=(value)
|
|
if value.blank?
|
|
self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false)
|
|
else
|
|
begin
|
|
normalized_value = Float(value)
|
|
raise ArgumentError unless normalized_value.finite?
|
|
|
|
self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false)
|
|
rescue ArgumentError, TypeError
|
|
# Store the raw value for validation error reporting
|
|
self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true)
|
|
end
|
|
end
|
|
end
|
|
|
|
validate :exchange_rate_must_be_valid
|
|
|
|
private
|
|
|
|
def exchange_rate_must_be_valid
|
|
if extra&.dig("exchange_rate_invalid")
|
|
errors.add(:exchange_rate, "must be a number")
|
|
elsif exchange_rate.present?
|
|
numeric_rate = Float(exchange_rate) rescue nil
|
|
if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0
|
|
errors.add(:exchange_rate, "must be greater than 0")
|
|
end
|
|
end
|
|
end
|
|
|
|
public
|
|
|
|
enum :kind, {
|
|
standard: "standard", # A regular transaction, included in budget analytics
|
|
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
|
|
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, treated as an expense in budgets
|
|
}
|
|
|
|
# All kinds where money moves between accounts (transfer? returns true).
|
|
# Used for search filters, rule conditions, and UI display.
|
|
TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment investment_contribution].freeze
|
|
|
|
# Kinds excluded from budget/income-statement analytics.
|
|
# loan_payment and investment_contribution are intentionally NOT here —
|
|
# they represent real cash outflow from a budgeting perspective.
|
|
BUDGET_EXCLUDED_KINDS = %w[funds_movement one_time cc_payment].freeze
|
|
|
|
# Kinds that never belong in the "Uncategorized" bucket, whichever surface
|
|
# asks for it (the Transactions category filter, the uncategorized badge
|
|
# count, the Quick Categorize wizard). These are the paired legs of a
|
|
# Transfer between the user's own accounts, so they have nothing to
|
|
# categorize. Every other kind with a NULL category does.
|
|
#
|
|
# This is deliberately neither of the two lists above:
|
|
# - vs TRANSFER_KINDS: loan_payment and investment_contribution are
|
|
# budget-tracked outflows the dashboard counts under Uncategorized, so
|
|
# hiding them left the dashboard figure unreproducible from the list
|
|
# with no way to find the transactions (see #2592).
|
|
# - vs BUDGET_EXCLUDED_KINDS: one_time is a real, categorizable
|
|
# expense/income that is only excluded from budget *analytics* so it
|
|
# doesn't skew medians. "Has no category" and "counts toward the
|
|
# budget" are different questions; only the former belongs here.
|
|
UNCATEGORIZED_EXCLUDED_KINDS = %w[funds_movement cc_payment].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
|
|
|
|
# Internal movement labels that should be excluded from budget (auto cash management)
|
|
INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze
|
|
|
|
# Providers that support pending transaction flags
|
|
PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up mercury redbark].freeze
|
|
|
|
# Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending.
|
|
# Stored as a constant so static analysis can verify it contains no user input.
|
|
PENDING_CHECK_SQL = PENDING_PROVIDERS
|
|
.map { |p| "(t.extra -> '#{p}' ->> 'pending')::boolean = true" }
|
|
.join(" OR ")
|
|
.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, -> {
|
|
conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean = true" }
|
|
where(conditions.join(" OR "))
|
|
}
|
|
|
|
scope :excluding_pending, -> {
|
|
conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true" }
|
|
where(conditions.join(" AND "))
|
|
}
|
|
|
|
# SQL snippet for raw queries that must exclude pending transactions.
|
|
# Use in income statements, balance sheets, and raw analytics.
|
|
def self.pending_providers_sql(table_alias = "t")
|
|
PENDING_PROVIDERS.map do |provider|
|
|
"AND (#{table_alias}.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true"
|
|
end.join("\n")
|
|
end
|
|
|
|
# Family-scoped query for Enrichable#clear_ai_cache
|
|
def self.family_scope(family)
|
|
joins(entry: :account).where(accounts: { family_id: family.id })
|
|
end
|
|
|
|
# Overarching grouping method for all transfer-type transactions
|
|
def transfer?
|
|
TRANSFER_KINDS.include?(kind)
|
|
end
|
|
|
|
def set_category!(category)
|
|
if category.is_a?(String)
|
|
category = entry.account.family.categories.find_or_create_by!(
|
|
name: category
|
|
)
|
|
end
|
|
|
|
update!(category: category)
|
|
end
|
|
|
|
# Marks a category as recently used. Called explicitly from the manual
|
|
# category-assignment controllers (picker, edit form, categorization
|
|
# wizard, create-and-assign) after a successful save — not wired to a
|
|
# blanket after_save callback because rule and import auto-assignment
|
|
# also go through `category_id=`, and those shouldn't count as a "recent"
|
|
# pick. See Category.recently_used_for.
|
|
def record_category_usage!
|
|
category.touch(:last_used_at) if saved_change_to_category_id? && category.present?
|
|
end
|
|
|
|
def pending?
|
|
extra_data = extra.is_a?(Hash) ? extra : {}
|
|
PENDING_PROVIDERS.any? do |provider|
|
|
ActiveModel::Type::Boolean.new.cast(extra_data.dig(provider, "pending"))
|
|
end
|
|
rescue StandardError
|
|
false
|
|
end
|
|
|
|
def activity_security_id
|
|
extra&.dig("security_id").presence || extra&.dig("security", "id").presence
|
|
end
|
|
|
|
def activity_security
|
|
security_id = activity_security_id.to_s
|
|
return @activity_security = nil if security_id.blank?
|
|
return @activity_security if defined?(@activity_security_id) && @activity_security_id == security_id
|
|
|
|
@activity_security_id = security_id
|
|
@activity_security = Security.find_by(id: security_id)
|
|
end
|
|
|
|
def set_preloaded_activity_security(security)
|
|
@activity_security_id = security&.id&.to_s
|
|
@activity_security = security
|
|
end
|
|
|
|
# Potential duplicate matching methods
|
|
# These help users review and resolve fuzzy-matched pending/posted pairs
|
|
|
|
def has_potential_duplicate?
|
|
potential_posted_match_data.present? && !potential_duplicate_dismissed?
|
|
end
|
|
|
|
# Manual recurring transactions are unique per (family, account, merchant/name, amount, currency)
|
|
# — see the partial unique indexes on recurring_transactions. Used to guard "mark as recurring"
|
|
# so the UI can disable the action ahead of time instead of failing after a POST.
|
|
def existing_manual_recurring_transaction
|
|
entry.account.family.recurring_transactions.find_by(
|
|
account_id: entry.account_id,
|
|
merchant_id: merchant_id,
|
|
name: merchant_id.present? ? nil : entry.name,
|
|
amount: entry.amount,
|
|
currency: entry.currency,
|
|
manual: true
|
|
)
|
|
end
|
|
|
|
def potential_duplicate_entry
|
|
return nil unless has_potential_duplicate?
|
|
Entry.find_by(id: potential_posted_match_data["entry_id"])
|
|
end
|
|
|
|
def potential_duplicate_reason
|
|
potential_posted_match_data&.dig("reason")
|
|
end
|
|
|
|
def potential_duplicate_confidence
|
|
potential_posted_match_data&.dig("confidence") || "medium"
|
|
end
|
|
|
|
def low_confidence_duplicate?
|
|
potential_duplicate_confidence == "low"
|
|
end
|
|
|
|
def potential_duplicate_posted_amount
|
|
potential_posted_match_data&.dig("posted_amount")&.to_d
|
|
end
|
|
|
|
def potential_duplicate_dismissed?
|
|
potential_posted_match_data&.dig("dismissed") == true
|
|
end
|
|
|
|
# Merge this pending transaction with its suggested posted match.
|
|
# The pending entry is destroyed; the posted entry survives with attributes inherited from both sides.
|
|
# Attribute inheritance: Date + Category from pending, Name + Merchant from posted (booked).
|
|
def merge_with_duplicate!
|
|
return false unless pending?
|
|
return false unless has_potential_duplicate?
|
|
|
|
posted_entry = potential_duplicate_entry
|
|
return false unless posted_entry
|
|
|
|
pending_entry = entry
|
|
|
|
# Guard: cross-account merges are never valid
|
|
if posted_entry.account_id != pending_entry.account_id
|
|
Rails.logger.warn("merge_with_duplicate! rejected: posted_entry #{posted_entry.id} belongs to different account than pending entry #{pending_entry.id}")
|
|
return false
|
|
end
|
|
|
|
pending_entry_id = pending_entry.id
|
|
merge_succeeded = false
|
|
|
|
ApplicationRecord.transaction(requires_new: true) do
|
|
# Row-level locks prevent concurrent merges on the same pair of entries.
|
|
# If a concurrent request already destroyed the pending entry, lock! raises
|
|
# RecordNotFound — treat that as an idempotent success.
|
|
begin
|
|
pending_entry.lock!
|
|
rescue ActiveRecord::RecordNotFound
|
|
Rails.logger.info("Pending entry #{pending_entry_id} already destroyed (concurrent merge), skipping")
|
|
return true
|
|
end
|
|
|
|
begin
|
|
posted_entry.lock!
|
|
rescue ActiveRecord::RecordNotFound
|
|
Rails.logger.info("Posted entry #{posted_entry.id} deleted concurrently; aborting merge")
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
# Capture after lock! (which reloads) to guarantee DB-fresh values and avoid
|
|
# stale in-memory cached associations (e.g., loaded via touch: true).
|
|
external_id = pending_entry.external_id
|
|
pending_entry_date = pending_entry.date
|
|
|
|
# Batch all changes to the surviving posted Transaction into a single update!
|
|
# to avoid firing after_save callbacks twice on the same row.
|
|
# Lock the Transaction row so concurrent merges into the same posted entry
|
|
# cannot race on reading/writing extra (e.g., the manual_merge array).
|
|
posted_tx = posted_entry.entryable
|
|
posted_tx.lock! if posted_tx.is_a?(Transaction)
|
|
if posted_tx.is_a?(Transaction)
|
|
tx_attrs = {}
|
|
|
|
# Merge metadata — always written so the sync engine can skip re-importing.
|
|
# Stored as an array so multiple pending entries merged into the same posted
|
|
# transaction each preserve their external_id for future sync exclusion.
|
|
# Legacy records written as a plain Hash are migrated to a single-element array
|
|
# on first append, maintaining backward compatibility.
|
|
if external_id.present?
|
|
new_record = {
|
|
"merged_from_entry_id" => pending_entry_id,
|
|
"merged_from_external_id" => external_id,
|
|
"merged_at" => Time.current.iso8601,
|
|
"source" => pending_entry.source
|
|
}
|
|
prior = case posted_tx.extra["manual_merge"]
|
|
when Array then posted_tx.extra["manual_merge"]
|
|
when Hash then [ posted_tx.extra["manual_merge"] ]
|
|
else []
|
|
end
|
|
tx_attrs[:extra] = posted_tx.extra.merge("manual_merge" => prior + [ new_record ])
|
|
end
|
|
|
|
# Attribute inheritance — only when the posted entry is not already user-protected.
|
|
unless posted_entry.protected_from_sync?
|
|
pending_transaction = pending_entry.entryable
|
|
if pending_transaction.is_a?(Transaction) && pending_transaction.category_id.present?
|
|
tx_attrs[:category_id] = pending_transaction.category_id
|
|
end
|
|
end
|
|
|
|
posted_tx.update!(tx_attrs) if tx_attrs.any?
|
|
end
|
|
|
|
# Date inheritance on the Entry row — separate from the Transaction update above.
|
|
unless posted_entry.protected_from_sync?
|
|
# Date: pending dates reflect actual transaction initiation time
|
|
posted_entry.update!(date: pending_entry_date) if posted_entry.date != pending_entry_date
|
|
# Name + Merchant intentionally NOT inherited — booked values are canonical
|
|
end
|
|
|
|
# Lock the posted entry so future syncs cannot overwrite the merged state
|
|
posted_entry.mark_user_modified!
|
|
|
|
Rails.logger.info("User merged pending entry #{pending_entry_id} (ext: #{external_id}) into posted entry #{posted_entry.id}")
|
|
pending_entry.destroy!
|
|
merge_succeeded = true
|
|
end
|
|
|
|
merge_succeeded
|
|
end
|
|
|
|
# Dismiss the duplicate suggestion - user says these are NOT the same transaction
|
|
def dismiss_duplicate_suggestion!
|
|
return false unless potential_posted_match_data.present?
|
|
|
|
updated_extra = (extra || {}).deep_dup
|
|
updated_extra["potential_posted_match"]["dismissed"] = true
|
|
update!(extra: updated_extra)
|
|
|
|
Rails.logger.info("User dismissed duplicate suggestion for entry #{entry.id}")
|
|
true
|
|
end
|
|
|
|
# Clear the duplicate suggestion entirely
|
|
def clear_duplicate_suggestion!
|
|
return false unless potential_posted_match_data.present?
|
|
|
|
updated_extra = (extra || {}).deep_dup
|
|
updated_extra.delete("potential_posted_match")
|
|
update!(extra: updated_extra)
|
|
true
|
|
end
|
|
|
|
# Find potential posted transactions that might be duplicates of this pending transaction
|
|
# Returns entries (not transactions) for UI consistency with transfer matcher
|
|
# Lists recent posted transactions from the same account for manual merging
|
|
def pending_duplicate_candidates(limit: 20, offset: 0)
|
|
return Entry.none unless pending? && entry.present?
|
|
|
|
account = entry.account
|
|
currency = entry.currency
|
|
|
|
# Find recent posted transactions from the same account
|
|
conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS NOT TRUE" }
|
|
|
|
account.entries
|
|
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
|
.where.not(id: entry.id)
|
|
.where(currency: currency)
|
|
.where(conditions.join(" AND "))
|
|
.order(date: :desc, created_at: :desc)
|
|
.limit(limit)
|
|
.offset(offset)
|
|
end
|
|
|
|
private
|
|
|
|
def validate_attachments
|
|
# Check attachment count limit
|
|
if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION
|
|
errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION)
|
|
end
|
|
|
|
# Validate each attachment
|
|
attachments.each_with_index do |attachment, index|
|
|
# Check file size
|
|
if attachment.byte_size > MAX_ATTACHMENT_SIZE
|
|
errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte)
|
|
end
|
|
|
|
# Check content type
|
|
unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type)
|
|
errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type)
|
|
end
|
|
end
|
|
end
|
|
|
|
def potential_posted_match_data
|
|
return nil unless extra.is_a?(Hash)
|
|
extra["potential_posted_match"]
|
|
end
|
|
|
|
def clear_merchant_unlinked_association
|
|
return unless merchant_id.present? && merchant.is_a?(ProviderMerchant)
|
|
|
|
family = entry&.account&.family
|
|
return unless family
|
|
|
|
FamilyMerchantAssociation.where(family: family, merchant: merchant).delete_all
|
|
end
|
|
end
|