mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 06:44:52 +00:00
* Add pending transaction handling and duplicate reconciliation logic - Implemented logic to exclude pending transactions from budgets and analytics calculations. - Introduced mechanisms for reconciling pending transactions with posted versions. - Added duplicate detection with support for merging or dismissing matches. - Updated transaction search filters to include a `status_filter` for pending/confirmed transactions. - Introduced UI elements for reviewing and resolving duplicates. - Enhanced `ProviderSyncSummary` with stats for reconciled and stale pending transactions. * Refactor translation handling and enhance transaction and sync logic - Moved hardcoded strings to locale files for improved translation support. - Refined styling for duplicate transaction indicators and sync summaries. - Improved logic for excluding stale pending transactions and updating timestamps on batch exclusion. - Added unique IDs to status filters for better element targeting in UI. - Optimized database queries to avoid N+1 issues in stale pending calculations. * Add sync settings and enhance pending transaction handling - Introduced a new "Sync Settings" section in hosting settings with UI to toggle inclusion of pending transactions. - Updated handling of pending transactions with improved inference logic for `posted=0` and `transacted_at` in processors. - Added priority order for pending transaction inclusion: explicit argument > environment variable > runtime configurable setting. - Refactored settings and controllers to store updated sync preferences. * Refactor sync settings and pending transaction reconciliation - Extracted logic for pending transaction reconciliation, stale exclusion, and unmatched tracking into dedicated methods for better maintainability. - Updated sync settings to infer defaults from multiple provider environment variables (`SIMPLEFIN_INCLUDE_PENDING`, `PLAID_INCLUDE_PENDING`). - Refined UI and messaging to handle multi-provider configurations in sync settings. # Conflicts: # app/models/simplefin_item/importer.rb * Debounce transaction reconciliation during imports - Added per-run reconciliation debouncing to prevent repeated scans for the same account during chunked history imports. - Trimmed size of reconciliation stats to retain recent details only. - Introduced error tracking for reconciliation steps to improve UI visibility of issues. * Apply ABS() in pending transaction queries and improve error handling - Updated pending transaction logic to use ABS() for consistent handling of negative amounts. - Adjusted amount bounds calculations to ensure accuracy for both positive and negative values. - Refined exception handling in `merge_duplicate` to log failures and update user alert. - Replaced `Date.today` with `Date.current` in tests to ensure timezone consistency. - Minor optimization to avoid COUNT queries by loading limited records directly. * Improve error handling in duplicate suggestion and dismissal logic - Added exception handling for `store_duplicate_suggestion` to log failures and prevent crashes during fuzzy/low-confidence matches. - Enhanced `dismiss_duplicate` action to handle `ActiveRecord::RecordInvalid` and display appropriate user alerts. --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
283 lines
11 KiB
Ruby
283 lines
11 KiB
Ruby
class Entry < ApplicationRecord
|
|
include Monetizable, Enrichable
|
|
|
|
monetize :amount
|
|
|
|
belongs_to :account
|
|
belongs_to :transfer, optional: true
|
|
belongs_to :import, optional: true
|
|
|
|
delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy
|
|
accepts_nested_attributes_for :entryable
|
|
|
|
validates :date, :name, :amount, :currency, presence: true
|
|
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
|
|
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
|
validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? }
|
|
|
|
scope :visible, -> {
|
|
joins(:account).where(accounts: { status: [ "draft", "active" ] })
|
|
}
|
|
|
|
scope :chronological, -> {
|
|
order(
|
|
date: :asc,
|
|
Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc,
|
|
created_at: :asc
|
|
)
|
|
}
|
|
|
|
scope :reverse_chronological, -> {
|
|
order(
|
|
date: :desc,
|
|
Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc,
|
|
created_at: :desc
|
|
)
|
|
}
|
|
|
|
# Pending transaction scopes - check Transaction.extra for provider pending flags
|
|
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
|
|
scope :pending, -> {
|
|
joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
|
.where(<<~SQL.squish)
|
|
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
|
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
|
SQL
|
|
}
|
|
|
|
scope :excluding_pending, -> {
|
|
# For non-Transaction entries (Trade, Valuation), always include
|
|
# For Transaction entries, exclude if pending flag is true
|
|
where(<<~SQL.squish)
|
|
entries.entryable_type != 'Transaction'
|
|
OR NOT EXISTS (
|
|
SELECT 1 FROM transactions t
|
|
WHERE t.id = entries.entryable_id
|
|
AND (
|
|
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
|
|
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
|
|
)
|
|
)
|
|
SQL
|
|
}
|
|
|
|
# Find stale pending transactions (pending for more than X days with no matching posted version)
|
|
scope :stale_pending, ->(days: 8) {
|
|
pending.where("entries.date < ?", days.days.ago.to_date)
|
|
}
|
|
|
|
# Auto-exclude stale pending transactions for an account
|
|
# Called during sync to clean up pending transactions that never posted
|
|
# @param account [Account] The account to clean up
|
|
# @param days [Integer] Number of days after which pending is considered stale (default: 8)
|
|
# @return [Integer] Number of entries excluded
|
|
def self.auto_exclude_stale_pending(account:, days: 8)
|
|
stale_entries = account.entries.stale_pending(days: days).where(excluded: false)
|
|
count = stale_entries.count
|
|
|
|
if count > 0
|
|
stale_entries.update_all(excluded: true, updated_at: Time.current)
|
|
Rails.logger.info("Auto-excluded #{count} stale pending transaction(s) for account #{account.id} (#{account.name})")
|
|
end
|
|
|
|
count
|
|
end
|
|
|
|
# Retroactively reconcile pending transactions that have a matching posted version
|
|
# This handles duplicates created before reconciliation code was deployed
|
|
#
|
|
# @param account [Account, nil] Specific account to clean up, or nil for all accounts
|
|
# @param dry_run [Boolean] If true, only report what would be done without making changes
|
|
# @param date_window [Integer] Days to search forward for posted matches (default: 8)
|
|
# @param amount_tolerance [Float] Percentage difference allowed for fuzzy matching (default: 0.25)
|
|
# @return [Hash] Stats about what was reconciled
|
|
def self.reconcile_pending_duplicates(account: nil, dry_run: false, date_window: 8, amount_tolerance: 0.25)
|
|
stats = { checked: 0, reconciled: 0, details: [] }
|
|
|
|
# Get pending entries to check
|
|
scope = Entry.pending.where(excluded: false)
|
|
scope = scope.where(account: account) if account
|
|
|
|
scope.includes(:account, :entryable).find_each do |pending_entry|
|
|
stats[:checked] += 1
|
|
acct = pending_entry.account
|
|
|
|
# PRIORITY 1: Look for posted transaction with EXACT amount match
|
|
# CRITICAL: Only search forward in time - posted date must be >= pending date
|
|
exact_candidates = acct.entries
|
|
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
|
.where.not(id: pending_entry.id)
|
|
.where(currency: pending_entry.currency)
|
|
.where(amount: pending_entry.amount)
|
|
.where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending date
|
|
.where(<<~SQL.squish)
|
|
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
|
|
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
|
|
SQL
|
|
.limit(2) # Only need to know if 0, 1, or 2+ candidates
|
|
.to_a # Load limited records to avoid COUNT(*) on .size
|
|
|
|
# Handle exact match - auto-exclude only if exactly ONE candidate (high confidence)
|
|
# Multiple candidates = ambiguous = skip to avoid excluding wrong entry
|
|
if exact_candidates.size == 1
|
|
posted_match = exact_candidates.first
|
|
detail = {
|
|
pending_id: pending_entry.id,
|
|
pending_name: pending_entry.name,
|
|
pending_amount: pending_entry.amount.to_f,
|
|
pending_date: pending_entry.date,
|
|
posted_id: posted_match.id,
|
|
posted_name: posted_match.name,
|
|
posted_amount: posted_match.amount.to_f,
|
|
posted_date: posted_match.date,
|
|
account: acct.name,
|
|
match_type: "exact"
|
|
}
|
|
stats[:details] << detail
|
|
stats[:reconciled] += 1
|
|
|
|
unless dry_run
|
|
pending_entry.update!(excluded: true)
|
|
Rails.logger.info("Reconciled pending→posted duplicate: excluded entry #{pending_entry.id} (#{pending_entry.name}) matched to #{posted_match.id}")
|
|
end
|
|
next
|
|
end
|
|
|
|
# PRIORITY 2: If no exact match, try fuzzy amount match for tip adjustments
|
|
# Store as SUGGESTION instead of auto-excluding (medium confidence)
|
|
pending_amount = pending_entry.amount.abs
|
|
min_amount = pending_amount
|
|
max_amount = pending_amount * (1 + amount_tolerance)
|
|
|
|
fuzzy_date_window = 3
|
|
candidates = acct.entries
|
|
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
|
.where.not(id: pending_entry.id)
|
|
.where(currency: pending_entry.currency)
|
|
.where(date: pending_entry.date..(pending_entry.date + fuzzy_date_window.days)) # Posted ON or AFTER pending
|
|
.where("ABS(entries.amount) BETWEEN ? AND ?", min_amount, max_amount)
|
|
.where(<<~SQL.squish)
|
|
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
|
|
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
|
|
SQL
|
|
|
|
# Match by name similarity (first 3 words)
|
|
name_words = pending_entry.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
|
if name_words.present?
|
|
matching_candidates = candidates.select do |c|
|
|
c_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
|
name_words == c_words
|
|
end
|
|
|
|
# Only suggest if there's exactly ONE matching candidate
|
|
# Multiple matches = ambiguous (e.g., recurring gas station visits) = skip
|
|
if matching_candidates.size == 1
|
|
fuzzy_match = matching_candidates.first
|
|
|
|
detail = {
|
|
pending_id: pending_entry.id,
|
|
pending_name: pending_entry.name,
|
|
pending_amount: pending_entry.amount.to_f,
|
|
pending_date: pending_entry.date,
|
|
posted_id: fuzzy_match.id,
|
|
posted_name: fuzzy_match.name,
|
|
posted_amount: fuzzy_match.amount.to_f,
|
|
posted_date: fuzzy_match.date,
|
|
account: acct.name,
|
|
match_type: "fuzzy_suggestion"
|
|
}
|
|
stats[:details] << detail
|
|
|
|
unless dry_run
|
|
# Store suggestion on the pending entry instead of auto-excluding
|
|
pending_transaction = pending_entry.entryable
|
|
if pending_transaction.is_a?(Transaction)
|
|
existing_extra = pending_transaction.extra || {}
|
|
unless existing_extra["potential_posted_match"].present?
|
|
pending_transaction.update!(
|
|
extra: existing_extra.merge(
|
|
"potential_posted_match" => {
|
|
"entry_id" => fuzzy_match.id,
|
|
"reason" => "fuzzy_amount_match",
|
|
"posted_amount" => fuzzy_match.amount.to_s,
|
|
"detected_at" => Date.current.to_s
|
|
}
|
|
)
|
|
)
|
|
Rails.logger.info("Stored duplicate suggestion for entry #{pending_entry.id} (#{pending_entry.name}) → #{fuzzy_match.id}")
|
|
end
|
|
end
|
|
end
|
|
elsif matching_candidates.size > 1
|
|
Rails.logger.info("Skipping fuzzy reconciliation for #{pending_entry.id} (#{pending_entry.name}): #{matching_candidates.size} ambiguous candidates")
|
|
end
|
|
end
|
|
end
|
|
|
|
stats
|
|
end
|
|
|
|
def classification
|
|
amount.negative? ? "income" : "expense"
|
|
end
|
|
|
|
def lock_saved_attributes!
|
|
super
|
|
entryable.lock_saved_attributes!
|
|
end
|
|
|
|
def sync_account_later
|
|
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
|
|
account.sync_later(window_start_date: sync_start_date)
|
|
end
|
|
|
|
def entryable_name_short
|
|
entryable_type.demodulize.underscore
|
|
end
|
|
|
|
def balance_trend(entries, balances)
|
|
Balance::TrendCalculator.new(self, entries, balances).trend
|
|
end
|
|
|
|
def linked?
|
|
external_id.present?
|
|
end
|
|
|
|
class << self
|
|
def search(params)
|
|
EntrySearch.new(params).build_query(all)
|
|
end
|
|
|
|
# arbitrary cutoff date to avoid expensive sync operations
|
|
def min_supported_date
|
|
30.years.ago.to_date
|
|
end
|
|
|
|
def bulk_update!(bulk_update_params)
|
|
bulk_attributes = {
|
|
date: bulk_update_params[:date],
|
|
notes: bulk_update_params[:notes],
|
|
entryable_attributes: {
|
|
category_id: bulk_update_params[:category_id],
|
|
merchant_id: bulk_update_params[:merchant_id],
|
|
tag_ids: bulk_update_params[:tag_ids]
|
|
}.compact_blank
|
|
}.compact_blank
|
|
|
|
return 0 if bulk_attributes.blank?
|
|
|
|
transaction do
|
|
all.each do |entry|
|
|
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
|
|
entry.update! bulk_attributes
|
|
|
|
entry.lock_saved_attributes!
|
|
entry.entryable.lock_attr!(:tag_ids) if entry.transaction? && entry.transaction.tags.any?
|
|
end
|
|
end
|
|
|
|
all.size
|
|
end
|
|
end
|
|
end
|