diff --git a/CLAUDE.md b/CLAUDE.md index f543d5bd9..eb1688d90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,10 +100,14 @@ Two primary data ingestion methods: - SimpleFIN: pending via `pending: true` or `posted` blank/0 + `transacted_at`. - Plaid: pending via Plaid `pending: true` (stored at `extra["plaid"]["pending"]` for bank/credit transactions imported via `PlaidEntry::Processor`). - Storage: provider data on `Transaction#extra` (e.g., `extra["simplefin"]["pending"]`; FX uses `fx_from`, `fx_date`). -- UI: “Pending” badge when `transaction.pending?` is true; no badge if provider omits pendings. -- Configuration (default-off) - - Centralized in `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`. - - ENV-backed keys: `SIMPLEFIN_INCLUDE_PENDING=1`, `SIMPLEFIN_DEBUG_RAW=1`. +- UI: "Pending" badge when `transaction.pending?` is true; no badge if provider omits pendings. +- Configuration (default-on for pending) + - SimpleFIN: `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`. + - Plaid: `config/initializers/plaid_config.rb` via `Rails.configuration.x.plaid.*`. + - Pending transactions are fetched by default and handled via reconciliation/filtering. + - Set `SIMPLEFIN_INCLUDE_PENDING=0` to disable pending fetching for SimpleFIN. + - Set `PLAID_INCLUDE_PENDING=0` to disable pending fetching for Plaid. + - Set `SIMPLEFIN_DEBUG_RAW=1` to enable raw payload debug logging. Provider support notes: - SimpleFIN: supports pending + FX metadata (stored under `extra["simplefin"]`). diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index f46053401..4c586a09d 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -46,6 +46,42 @@ "data-auto-submit-form-target": "auto" %> + + <%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %> + <% menu.with_button( + id: "activity-status-filter-button", + type: "button", + text: t("accounts.show.activity.filter"), + variant: "outline", + icon: "list-filter" + ) %> + + <% menu.with_custom_content do %> +
+

<%= t("accounts.show.activity.status") %>

+
+ <%= check_box_tag "q[status][]", + "confirmed", + params.dig(:q, :status)&.include?("confirmed"), + id: "q_status_confirmed", + class: "checkbox checkbox--light", + form: "entries-search", + onchange: "document.getElementById('entries-search').requestSubmit()" %> + <%= label_tag "q_status_confirmed", t("accounts.show.activity.confirmed"), class: "text-sm text-primary" %> +
+
+ <%= check_box_tag "q[status][]", + "pending", + params.dig(:q, :status)&.include?("pending"), + id: "q_status_pending", + class: "checkbox checkbox--light", + form: "entries-search", + onchange: "document.getElementById('entries-search').requestSubmit()" %> + <%= label_tag "q_status_pending", t("accounts.show.activity.pending"), class: "text-sm text-primary" %> +
+
+ <% end %> + <% end %> <% end %> <%= button_tag type: "button", id: "toggle-checkboxes-button", diff --git a/app/components/provider_sync_summary.html.erb b/app/components/provider_sync_summary.html.erb index 488a86004..184671c8e 100644 --- a/app/components/provider_sync_summary.html.erb +++ b/app/components/provider_sync_summary.html.erb @@ -69,6 +69,102 @@ <% end %> + <%# Pending→posted reconciliation %> + <% if has_pending_reconciled? %> +
+
+ <%= helpers.icon "check-circle", size: "sm", color: "success" %> + <%= t("provider_sync_summary.health.pending_reconciled", count: pending_reconciled) %> +
+ <% if pending_reconciled_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_reconciled") %> + +
+ <% pending_reconciled_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= detail["pending_name"] %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# Duplicate suggestions needing review %> + <% if has_duplicate_suggestions_created? %> +
+
+ <%= helpers.icon "alert-triangle", size: "sm", color: "warning" %> + <%= t("provider_sync_summary.health.duplicate_suggestions", count: duplicate_suggestions_created) %> +
+ <% if duplicate_suggestions_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_duplicate_suggestions") %> + +
+ <% duplicate_suggestions_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= detail["pending_name"] %> → <%= detail["posted_name"] %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# Stale pending transactions (auto-excluded) %> + <% if has_stale_pending? %> +
+
+ <%= helpers.icon "clock", size: "sm", color: "warning" %> + <%= t("provider_sync_summary.health.stale_pending", count: stale_pending_excluded) %> +
+ <% if stale_pending_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_stale_pending") %> + +
+ <% stale_pending_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_pending_count", count: detail["count"]) %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# Stale unmatched pending (need manual review) %> + <% if has_stale_unmatched_pending? %> +
+
+ <%= helpers.icon "help-circle", size: "sm" %> + <%= t("provider_sync_summary.health.stale_unmatched", count: stale_unmatched_pending) %> +
+ <% if stale_unmatched_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_stale_unmatched") %> + +
+ <% stale_unmatched_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_unmatched_count", count: detail["count"]) %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + <%# Data quality warnings %> <% if has_data_quality_issues? %>
diff --git a/app/components/provider_sync_summary.rb b/app/components/provider_sync_summary.rb index 4d00f2343..19d9aceb7 100644 --- a/app/components/provider_sync_summary.rb +++ b/app/components/provider_sync_summary.rb @@ -127,6 +127,58 @@ class ProviderSyncSummary < ViewComponent::Base total_errors > 0 end + # Stale pending transactions (auto-excluded) + def stale_pending_excluded + stats["stale_pending_excluded"].to_i + end + + def has_stale_pending? + stale_pending_excluded > 0 + end + + def stale_pending_details + stats["stale_pending_details"] || [] + end + + # Stale unmatched pending (need manual review - couldn't be automatically matched) + def stale_unmatched_pending + stats["stale_unmatched_pending"].to_i + end + + def has_stale_unmatched_pending? + stale_unmatched_pending > 0 + end + + def stale_unmatched_details + stats["stale_unmatched_details"] || [] + end + + # Pending→posted reconciliation stats + def pending_reconciled + stats["pending_reconciled"].to_i + end + + def has_pending_reconciled? + pending_reconciled > 0 + end + + def pending_reconciled_details + stats["pending_reconciled_details"] || [] + end + + # Duplicate suggestions needing user review + def duplicate_suggestions_created + stats["duplicate_suggestions_created"].to_i + end + + def has_duplicate_suggestions_created? + duplicate_suggestions_created > 0 + end + + def duplicate_suggestions_details + stats["duplicate_suggestions_details"] || [] + end + # Data quality / warnings def data_warnings stats["data_warnings"].to_i diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 48e484f40..b71318b62 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -35,8 +35,8 @@ class AccountsController < ApplicationController def show @chart_view = params[:chart_view] || "balance" @tab = params[:tab] - @q = params.fetch(:q, {}).permit(:search) - entries = @account.entries.search(@q).reverse_chronological + @q = params.fetch(:q, {}).permit(:search, status: []) + entries = @account.entries.where(excluded: false).search(@q).reverse_chronological @pagy, @entries = pagy(entries, limit: params[:per_page] || "10") diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 9300a0b2c..20e848ab4 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -58,6 +58,10 @@ class Settings::HostingsController < ApplicationController Setting.securities_provider = hosting_params[:securities_provider] end + if hosting_params.key?(:syncs_include_pending) + Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1" + end + if hosting_params.key?(:openai_access_token) token_param = hosting_params[:openai_access_token].to_s.strip # Ignore blanks and redaction placeholders to prevent accidental overwrite @@ -99,7 +103,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending) end def ensure_admin diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 07e829ebd..a0fc9aaee 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -116,6 +116,38 @@ class TransactionsController < ApplicationController end end + def merge_duplicate + transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + + 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::RecordNotDestroyed, ActiveRecord::RecordInvalid => 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 = Current.family.transactions.includes(entry: :account).find(params[:id]) + + 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 mark_as_recurring transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) @@ -205,7 +237,7 @@ class TransactionsController < ApplicationController :start_date, :end_date, :search, :amount, :amount_operator, :active_accounts_only, accounts: [], account_ids: [], - categories: [], merchants: [], types: [], tags: [] + categories: [], merchants: [], types: [], tags: [], status: [] ) .to_h .compact_blank diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index 024d742be..92ce26c81 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -1,13 +1,14 @@ module TransactionsHelper def transaction_search_filters [ - { key: "account_filter", label: "Account", icon: "layers" }, - { key: "date_filter", label: "Date", icon: "calendar" }, - { key: "type_filter", label: "Type", icon: "tag" }, - { key: "amount_filter", label: "Amount", icon: "hash" }, - { key: "category_filter", label: "Category", icon: "shapes" }, - { key: "tag_filter", label: "Tag", icon: "tags" }, - { key: "merchant_filter", label: "Merchant", icon: "store" } + { key: "account_filter", label: t("transactions.search.filters.account"), icon: "layers" }, + { key: "date_filter", label: t("transactions.search.filters.date"), icon: "calendar" }, + { key: "type_filter", label: t("transactions.search.filters.type"), icon: "tag" }, + { key: "status_filter", label: t("transactions.search.filters.status"), icon: "clock" }, + { key: "amount_filter", label: t("transactions.search.filters.amount"), icon: "hash" }, + { key: "category_filter", label: t("transactions.search.filters.category"), icon: "shapes" }, + { key: "tag_filter", label: t("transactions.search.filters.tag"), icon: "tags" }, + { key: "merchant_filter", label: t("transactions.search.filters.merchant"), icon: "store" } ] end diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 52964b165..d247e09e0 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -16,9 +16,10 @@ class Account::ProviderImportAdapter # @param category_id [Integer, nil] Optional category ID # @param merchant [Merchant, nil] Optional merchant object # @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 # @return [Entry] The created or updated entry - def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: 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) raise ArgumentError, "external_id is required" if external_id.blank? raise ArgumentError, "source is required" if source.blank? @@ -43,6 +44,42 @@ class Account::ProviderImportAdapter end end + # If still a new entry and this is a POSTED transaction, check for matching pending transactions + incoming_pending = extra.is_a?(Hash) && ( + ActiveModel::Type::Boolean.new.cast(extra.dig("simplefin", "pending")) || + ActiveModel::Type::Boolean.new.cast(extra.dig("plaid", "pending")) + ) + + if entry.new_record? && !incoming_pending + pending_match = nil + + # PRIORITY 1: Use Plaid's pending_transaction_id if provided (most reliable) + # Plaid explicitly links pending→posted with this ID - no guessing required + if pending_transaction_id.present? + pending_match = account.entries.find_by(external_id: pending_transaction_id, source: source) + if pending_match + Rails.logger.info("Reconciling pending→posted via Plaid pending_transaction_id: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}") + end + end + + # PRIORITY 2: Fallback to EXACT amount match (for SimpleFIN and providers without linking IDs) + # Only searches backward in time - pending date must be <= posted date + if pending_match.nil? + pending_match = find_pending_transaction(date: date, amount: amount, currency: currency, source: source) + if pending_match + Rails.logger.info("Reconciling pending→posted via exact amount match: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}") + end + end + + if pending_match + entry = pending_match + entry.assign_attributes(external_id: external_id) + end + end + + # Track if this is a new posted transaction (for fuzzy suggestion after save) + is_new_posted = entry.new_record? && !incoming_pending + # Validate entryable type matches to prevent external_id collisions if entry.persisted? && !entry.entryable.is_a?(Transaction) raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}" @@ -78,6 +115,60 @@ class Account::ProviderImportAdapter entry.transaction.save! end entry.save! + + # 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 + if is_new_posted + # PRIORITY 1: Try medium-confidence fuzzy match (≤30% amount difference) + fuzzy_suggestion = find_pending_transaction_fuzzy( + date: date, + amount: amount, + currency: currency, + source: source, + merchant_id: merchant&.id, + name: name + ) + if fuzzy_suggestion + # Store suggestion on the PENDING entry for user to review + begin + store_duplicate_suggestion( + pending_entry: fuzzy_suggestion, + posted_entry: entry, + reason: "fuzzy_amount_match", + posted_amount: amount, + confidence: "medium" + ) + Rails.logger.info("Suggested potential duplicate (medium confidence): pending entry #{fuzzy_suggestion.id} (#{fuzzy_suggestion.name}, #{fuzzy_suggestion.amount}) may match posted #{entry.name} (#{amount})") + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("Failed to store duplicate suggestion for entry #{fuzzy_suggestion.id}: #{e.message}") + end + else + # PRIORITY 2: Try low-confidence match (>30% to 100% difference - big tips) + low_confidence_suggestion = find_pending_transaction_low_confidence( + date: date, + amount: amount, + currency: currency, + source: source, + merchant_id: merchant&.id, + name: name + ) + if low_confidence_suggestion + begin + store_duplicate_suggestion( + pending_entry: low_confidence_suggestion, + posted_entry: entry, + reason: "low_confidence_match", + posted_amount: amount, + confidence: "low" + ) + Rails.logger.info("Suggested potential duplicate (low confidence): pending entry #{low_confidence_suggestion.id} (#{low_confidence_suggestion.name}, #{low_confidence_suggestion.amount}) may match posted #{entry.name} (#{amount})") + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("Failed to store duplicate suggestion for entry #{low_confidence_suggestion.id}: #{e.message}") + end + end + end + end + entry end end @@ -444,4 +535,196 @@ class Account::ProviderImportAdapter query.order(created_at: :asc).first end + + # Finds a pending transaction that likely matches a newly posted transaction + # Used to reconcile pending→posted when SimpleFIN gives different IDs for the same transaction + # + # @param date [Date, String] Posted transaction date + # @param amount [BigDecimal, Numeric] Transaction amount (must match exactly) + # @param currency [String] Currency code + # @param source [String] Provider name (e.g., "simplefin") + # @param date_window [Integer] Days to search around the posted date (default: 8) + # @return [Entry, nil] The pending entry or nil if not found + def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8) + date = Date.parse(date.to_s) unless date.is_a?(Date) + + # Look for entries that: + # 1. Same account (implicit via account.entries) + # 2. Same source (simplefin) + # 3. Same amount (exact match - this is the strongest signal) + # 4. Same currency + # 5. Date within window (pending can post days later) + # 6. Is a Transaction (not Trade or Valuation) + # 7. Has pending=true in transaction.extra["simplefin"]["pending"] or extra["plaid"]["pending"] + candidates = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: source) + .where(amount: amount) + .where(currency: currency) + .where(date: (date - date_window.days)..date) # Pending must be ON or BEFORE posted date + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + .order(date: :desc) # Prefer most recent pending transaction + + candidates.first + end + + # Finds a pending transaction using fuzzy amount matching for tip adjustments + # Used when exact amount matching fails - handles restaurant tips, adjusted authorizations, etc. + # + # IMPORTANT: Only returns a match if there's exactly ONE candidate to avoid false positives + # with recurring merchant transactions (e.g., gas stations, coffee shops). + # + # @param date [Date, String] Posted transaction date + # @param amount [BigDecimal, Numeric] Posted transaction amount (typically higher due to tip) + # @param currency [String] Currency code + # @param source [String] Provider name (e.g., "simplefin") + # @param merchant_id [Integer, nil] Merchant ID for more accurate matching + # @param name [String, nil] Transaction name for fuzzy name matching + # @param date_window [Integer] Days to search backward from posted date (default: 3 for fuzzy) + # @param amount_tolerance [Float] Maximum percentage difference allowed (default: 0.30 = 30%) + # @return [Entry, nil] The pending entry or nil if not found/ambiguous + def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3, amount_tolerance: 0.30) + date = Date.parse(date.to_s) unless date.is_a?(Date) + amount = BigDecimal(amount.to_s) + + # Calculate amount bounds using ABS to handle both positive and negative amounts + # Posted amount should be >= pending (tips add, not subtract) + # Allow posted to be up to 30% higher than pending (covers typical tips) + abs_amount = amount.abs + min_pending_abs = abs_amount / (1 + amount_tolerance) # If posted is 100, pending could be as low as ~77 + max_pending_abs = abs_amount # Pending should not be higher than posted + + # Build base query for pending transactions + # CRITICAL: Pending must be ON or BEFORE the posted date (authorization happens first) + # Use tighter date window (3 days) - tips post quickly, not a week later + # Use ABS() for amount comparison to handle negative amounts correctly + candidates = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: source) + .where(currency: currency) + .where(date: (date - date_window.days)..date) # Pending ON or BEFORE posted + .where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + # If merchant_id is provided, prioritize matching by merchant + if merchant_id.present? + merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a + # Only match if exactly ONE candidate to avoid false positives + return merchant_matches.first if merchant_matches.size == 1 + if merchant_matches.size > 1 + Rails.logger.info("Skipping fuzzy pending match: #{merchant_matches.size} ambiguous merchant candidates for amount=#{amount} date=#{date}") + end + end + + # If name is provided, try fuzzy name matching as fallback + if name.present? + # Extract first few significant words for comparison + name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + if name_words.present? + name_matches = candidates.select do |c| + c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + name_words == c_name_words + end + # Only match if exactly ONE candidate to avoid false positives + return name_matches.first if name_matches.size == 1 + if name_matches.size > 1 + Rails.logger.info("Skipping fuzzy pending match: #{name_matches.size} ambiguous name candidates for '#{name_words}' amount=#{amount} date=#{date}") + end + end + end + + # No merchant or name match, return nil (too risky to match on amount alone) + # This prevents false positives when multiple pending transactions exist + nil + end + + # Finds a pending transaction with low confidence (>30% to 100% amount difference) + # Used for large tip scenarios where normal fuzzy matching would miss + # Creates a "review recommended" suggestion rather than "possible duplicate" + # + # @param date [Date, String] Posted transaction date + # @param amount [BigDecimal, Numeric] Posted transaction amount + # @param currency [String] Currency code + # @param source [String] Provider name + # @param merchant_id [Integer, nil] Merchant ID for matching + # @param name [String, nil] Transaction name for matching + # @param date_window [Integer] Days to search backward (default: 3) + # @return [Entry, nil] The pending entry or nil if not found/ambiguous + def find_pending_transaction_low_confidence(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3) + date = Date.parse(date.to_s) unless date.is_a?(Date) + amount = BigDecimal(amount.to_s) + + # Allow up to 100% difference (e.g., $50 pending → $100 posted with huge tip) + # This is low confidence - requires strong name/merchant match + # Use ABS to handle both positive and negative amounts correctly + abs_amount = amount.abs + min_pending_abs = abs_amount / 2.0 # Posted could be up to 2x pending + max_pending_abs = abs_amount * 0.77 # Pending must be at least 30% less (to not overlap with fuzzy) + + # Build base query for pending transactions + # Use ABS() for amount comparison to handle negative amounts correctly + candidates = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: source) + .where(currency: currency) + .where(date: (date - date_window.days)..date) + .where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + # For low confidence, require BOTH merchant AND name match (stronger signal needed) + if merchant_id.present? && name.present? + name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + return nil if name_words.blank? + + merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a + name_matches = merchant_matches.select do |c| + c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + name_words == c_name_words + end + + # Only match if exactly ONE candidate + return name_matches.first if name_matches.size == 1 + end + + nil + end + + # Stores a duplicate suggestion on a pending entry for user review + # The suggestion is stored in the pending transaction's extra field + # + # @param pending_entry [Entry] The pending entry that may be a duplicate + # @param posted_entry [Entry] The posted entry it may match + # @param reason [String] Why this was flagged (e.g., "fuzzy_amount_match", "low_confidence_match") + # @param posted_amount [BigDecimal] The posted transaction amount + # @param confidence [String] Confidence level: "medium" (≤30% diff) or "low" (>30% diff) + def store_duplicate_suggestion(pending_entry:, posted_entry:, reason:, posted_amount:, confidence: "medium") + return unless pending_entry&.entryable.is_a?(Transaction) + + pending_transaction = pending_entry.entryable + existing_extra = pending_transaction.extra || {} + + # Don't overwrite if already has a suggestion (keep first one found) + return if existing_extra["potential_posted_match"].present? + + pending_transaction.update!( + extra: existing_extra.merge( + "potential_posted_match" => { + "entry_id" => posted_entry.id, + "reason" => reason, + "posted_amount" => posted_amount.to_s, + "confidence" => confidence, + "detected_at" => Date.current.to_s + } + ) + ) + end end diff --git a/app/models/entry.rb b/app/models/entry.rb index 3a6672b7d..533e389e2 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -35,6 +35,188 @@ class Entry < ApplicationRecord ) } + # 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 diff --git a/app/models/entry_search.rb b/app/models/entry_search.rb index 07793df47..7d81dda55 100644 --- a/app/models/entry_search.rb +++ b/app/models/entry_search.rb @@ -6,6 +6,7 @@ class EntrySearch attribute :amount, :string attribute :amount_operator, :string attribute :types, :string + attribute :status, array: true attribute :accounts, array: true attribute :account_ids, array: true attribute :start_date, :string @@ -56,6 +57,44 @@ class EntrySearch query = query.where(accounts: { id: account_ids }) if account_ids.present? query end + + def apply_status_filter(scope, statuses) + return scope unless statuses.present? + return scope if statuses.uniq.sort == %w[confirmed pending] # Both selected = no filter + + pending_condition = <<~SQL.squish + entries.entryable_type = 'Transaction' + AND 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 + + confirmed_condition = <<~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 + + case statuses.sort + when [ "pending" ] + scope.where(pending_condition) + when [ "confirmed" ] + scope.where(confirmed_condition) + else + scope + end + end end def build_query(scope) @@ -64,6 +103,7 @@ class EntrySearch query = self.class.apply_date_filters(query, start_date, end_date) query = self.class.apply_amount_filter(query, amount, amount_operator) query = self.class.apply_accounts_filter(query, accounts, account_ids) + query = self.class.apply_status_filter(query, status) query end end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 6c4e16b90..16982eba6 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -12,7 +12,9 @@ class IncomeStatement end def totals(transactions_scope: nil, date_range:) - transactions_scope ||= family.transactions.visible + # Default to excluding pending transactions from budget/analytics calculations + # Pending transactions shouldn't affect budget totals until they post + transactions_scope ||= family.transactions.visible.excluding_pending result = totals_query(transactions_scope: transactions_scope, date_range: date_range) @@ -64,7 +66,8 @@ class IncomeStatement end def build_period_total(classification:, period:) - totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification } + # Exclude pending transactions from budget calculations + totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification } classification_total = totals.sum(&:total) uncategorized_category = family.categories.uncategorized diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index 3daf69c5a..3bc91b839 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -49,6 +49,8 @@ class IncomeStatement::CategoryStats WHERE a.family_id = :family_id AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false + AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index a2509b114..c2a3c8f8e 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -46,6 +46,8 @@ class IncomeStatement::FamilyStats WHERE a.family_id = :family_id AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false + AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb index 90e55d2d9..a59045a37 100644 --- a/app/models/plaid_account/transactions/processor.rb +++ b/app/models/plaid_account/transactions/processor.rb @@ -51,7 +51,20 @@ class PlaidAccount::Transactions::Processor modified = plaid_account.raw_transactions_payload["modified"] || [] added = plaid_account.raw_transactions_payload["added"] || [] - modified + added + transactions = modified + added + + # Filter out pending transactions based on env var or Setting + # Priority: env var > Setting (allows runtime changes via UI) + include_pending = if ENV["PLAID_INCLUDE_PENDING"].present? + Rails.configuration.x.plaid.include_pending + else + Setting.syncs_include_pending + end + unless include_pending + transactions = transactions.reject { |t| t["pending"] == true } + end + + transactions end def removed_transactions diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb index e3bc8c74c..c0a890038 100644 --- a/app/models/plaid_entry/processor.rb +++ b/app/models/plaid_entry/processor.rb @@ -16,9 +16,11 @@ class PlaidEntry::Processor source: "plaid", category_id: matched_category&.id, merchant: merchant, + pending_transaction_id: pending_transaction_id, # Plaid's linking ID for pending→posted extra: { plaid: { - pending: plaid_transaction["pending"] + pending: plaid_transaction["pending"], + pending_transaction_id: pending_transaction_id # Also store for reference } } ) @@ -55,6 +57,12 @@ class PlaidEntry::Processor plaid_transaction["date"] end + # Plaid provides this linking ID when a posted transaction matches a pending one + # This is the most reliable way to reconcile pending→posted + def pending_transaction_id + plaid_transaction["pending_transaction_id"] + end + def detailed_category plaid_transaction.dig("personal_finance_category", "detailed") end diff --git a/app/models/setting.rb b/app/models/setting.rb index 1b5706171..e54817911 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -16,6 +16,15 @@ class Setting < RailsSettings::Base field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data") field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data") + # Sync settings - check both provider env vars for default + # Only defaults to true if neither provider explicitly disables pending + SYNCS_INCLUDE_PENDING_DEFAULT = begin + simplefin = ENV.fetch("SIMPLEFIN_INCLUDE_PENDING", "1") == "1" + plaid = ENV.fetch("PLAID_INCLUDE_PENDING", "1") == "1" + simplefin && plaid + end + field :syncs_include_pending, type: :boolean, default: SYNCS_INCLUDE_PENDING_DEFAULT + # Dynamic fields are now stored as individual entries with "dynamic:" prefix # This prevents race conditions and ensures each field is independently managed diff --git a/app/models/simplefin_entry/processor.rb b/app/models/simplefin_entry/processor.rb index 4ef68449e..db4b5689b 100644 --- a/app/models/simplefin_entry/processor.rb +++ b/app/models/simplefin_entry/processor.rb @@ -34,9 +34,27 @@ class SimplefinEntry::Processor # Include provider-supplied extra hash if present sf["extra"] = data[:extra] if data[:extra].is_a?(Hash) - # Pending detection: only use explicit provider flag + # Pending detection: explicit flag OR inferred from posted=0 + transacted_at + # SimpleFIN indicates pending via: + # 1. pending: true (explicit flag) + # 2. posted=0 (epoch zero) + transacted_at present (implicit - some banks use this pattern) + # + # Note: We only infer from posted=0, NOT from posted=nil/blank, because some providers + # don't supply posted dates even for settled transactions (would cause false positives). # We always set the key (true or false) to ensure deep_merge overwrites any stale value - if ActiveModel::Type::Boolean.new.cast(data[:pending]) + is_pending = if ActiveModel::Type::Boolean.new.cast(data[:pending]) + true + else + # Infer pending ONLY when posted is explicitly 0 (epoch) AND transacted_at is present + # posted=nil/blank is NOT treated as pending (some providers omit posted for settled txns) + posted_val = data[:posted] + transacted_val = data[:transacted_at] + posted_is_epoch_zero = posted_val.present? && posted_val.to_i.zero? + transacted_present = transacted_val.present? && transacted_val.to_i > 0 + posted_is_epoch_zero && transacted_present + end + + if is_pending sf["pending"] = true Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}") else diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 45acd07b7..baab600b5 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -271,8 +271,8 @@ class SimplefinItem < ApplicationRecord return nil unless latest # If sync has statistics, use them - if latest.sync_stats.present? - stats = latest.sync_stats + stats = parse_sync_stats(latest.sync_stats) + if stats.present? total = stats["total_accounts"] || 0 linked = stats["linked_accounts"] || 0 unlinked = stats["unlinked_accounts"] || 0 @@ -399,7 +399,68 @@ class SimplefinItem < ApplicationRecord issues end + # Get reconciled duplicates count from the last sync + # Returns { count: N, message: "..." } or { count: 0 } if none + def last_sync_reconciled_status + latest_sync = syncs.ordered.first + return { count: 0 } unless latest_sync + + stats = parse_sync_stats(latest_sync.sync_stats) + count = stats&.dig("pending_reconciled").to_i + if count > 0 + { + count: count, + message: I18n.t("simplefin_items.reconciled_status.message", count: count) + } + else + { count: 0 } + end + end + + # Count stale pending transactions (>8 days old) across all linked accounts + # Returns { count: N, accounts: [names] } or { count: 0 } if none + def stale_pending_status(days: 8) + # Get all accounts linked to this SimpleFIN item + # Eager-load both association paths to avoid N+1 on current_account method + linked_accounts = simplefin_accounts.includes(:account, :linked_account).filter_map(&:current_account) + return { count: 0 } if linked_accounts.empty? + + # Batch query to avoid N+1 + account_ids = linked_accounts.map(&:id) + counts_by_account = Entry.stale_pending(days: days) + .where(excluded: false) + .where(account_id: account_ids) + .group(:account_id) + .count + + account_counts = linked_accounts + .map { |account| { account: account, count: counts_by_account[account.id].to_i } } + .select { |ac| ac[:count] > 0 } + + total = account_counts.sum { |ac| ac[:count] } + if total > 0 + { + count: total, + accounts: account_counts.map { |ac| ac[:account].name }, + message: I18n.t("simplefin_items.stale_pending_status.message", count: total, days: days) + } + else + { count: 0 } + end + end + private + # Parse sync_stats, handling cases where it might be a raw JSON string + # (e.g., from console testing or bypassed serialization) + def parse_sync_stats(sync_stats) + return nil if sync_stats.blank? + return sync_stats if sync_stats.is_a?(Hash) + + if sync_stats.is_a?(String) + JSON.parse(sync_stats) rescue nil + end + end + def remove_simplefin_item # SimpleFin doesn't require server-side cleanup like Plaid # The access URL just becomes inactive diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb index f4d3d9e3a..8d43993f8 100644 --- a/app/models/simplefin_item/importer.rb +++ b/app/models/simplefin_item/importer.rb @@ -9,6 +9,7 @@ class SimplefinItem::Importer @simplefin_provider = simplefin_provider @sync = sync @enqueued_holdings_job_ids = Set.new + @reconciled_account_ids = Set.new # Debounce pending reconciliation per run end def import @@ -201,8 +202,8 @@ class SimplefinItem::Importer end adapter.update_balance( - balance: normalized, - cash_balance: cash, + balance: account_data[:balance], + cash_balance: account_data[:"available-balance"], source: "simplefin" ) end @@ -428,9 +429,12 @@ class SimplefinItem::Importer perform_account_discovery # Step 2: Fetch transactions/holdings using the regular window. + # Note: Don't pass explicit `pending:` here - let fetch_accounts_data use the + # SIMPLEFIN_INCLUDE_PENDING config. This allows users to disable pending transactions + # if their bank's SimpleFIN integration produces duplicates when pending→posted. start_date = determine_sync_start_date Rails.logger.info "SimplefinItem::Importer - import_regular_sync: last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')} => start_date=#{start_date&.strftime('%Y-%m-%d')}" - accounts_data = fetch_accounts_data(start_date: start_date, pending: true) + accounts_data = fetch_accounts_data(start_date: start_date) return if accounts_data.nil? # Error already handled # Store raw payload @@ -554,9 +558,15 @@ class SimplefinItem::Importer # Returns a Hash payload with keys like :accounts, or nil when an error is # handled internally via `handle_errors`. def fetch_accounts_data(start_date:, end_date: nil, pending: nil) - # Determine whether to include pending based on explicit arg or global config. - # `Rails.configuration.x.simplefin.include_pending` is ENV-backed. - effective_pending = pending.nil? ? Rails.configuration.x.simplefin.include_pending : pending + # Determine whether to include pending based on explicit arg, env var, or Setting. + # Priority: explicit arg > env var > Setting (allows runtime changes via UI) + effective_pending = if !pending.nil? + pending + elsif ENV["SIMPLEFIN_INCLUDE_PENDING"].present? + Rails.configuration.x.simplefin.include_pending + else + Setting.syncs_include_pending + end # Debug logging to track exactly what's being sent to SimpleFin API start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none" @@ -806,6 +816,15 @@ class SimplefinItem::Importer # Post-save side effects acct = simplefin_account.current_account if acct + # Handle pending transaction reconciliation (debounced per run to avoid + # repeated scans during chunked history imports) + unless @reconciled_account_ids.include?(acct.id) + @reconciled_account_ids << acct.id + reconcile_and_track_pending_duplicates(acct) + exclude_and_track_stale_pending(acct) + track_stale_unmatched_pending(acct) + end + # Refresh credit attributes when available-balance present if acct.accountable_type == "CreditCard" && account_data[:"available-balance"].present? begin @@ -1146,19 +1165,103 @@ class SimplefinItem::Importer ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys end - # --- Simple helpers for numeric handling in normalization --- - def to_decimal(value) - return BigDecimal("0") if value.nil? - case value - when BigDecimal then value - when String then BigDecimal(value) rescue BigDecimal("0") - when Numeric then BigDecimal(value.to_s) - else - BigDecimal("0") + # Reconcile pending transactions that have a matching posted version + # Handles duplicates where pending and posted both exist (tip adjustments, etc.) + def reconcile_and_track_pending_duplicates(account) + reconcile_stats = Entry.reconcile_pending_duplicates(account: account, dry_run: false) + + exact_matches = reconcile_stats[:details].select { |d| d[:match_type] == "exact" } + fuzzy_suggestions = reconcile_stats[:details].select { |d| d[:match_type] == "fuzzy_suggestion" } + + if exact_matches.any? + stats["pending_reconciled"] = stats.fetch("pending_reconciled", 0) + exact_matches.size + stats["pending_reconciled_details"] ||= [] + exact_matches.each do |detail| + stats["pending_reconciled_details"] << { + "account_name" => detail[:account], + "pending_name" => detail[:pending_name], + "posted_name" => detail[:posted_name] + } + end + stats["pending_reconciled_details"] = stats["pending_reconciled_details"].last(50) end + + if fuzzy_suggestions.any? + stats["duplicate_suggestions_created"] = stats.fetch("duplicate_suggestions_created", 0) + fuzzy_suggestions.size + stats["duplicate_suggestions_details"] ||= [] + fuzzy_suggestions.each do |detail| + stats["duplicate_suggestions_details"] << { + "account_name" => detail[:account], + "pending_name" => detail[:pending_name], + "posted_name" => detail[:posted_name] + } + end + stats["duplicate_suggestions_details"] = stats["duplicate_suggestions_details"].last(50) + end + rescue => e + Rails.logger.warn("SimpleFin: pending reconciliation failed for account #{account.id}: #{e.class} - #{e.message}") + record_reconciliation_error("pending_reconciliation", account, e) end - def same_sign?(a, b) - (a.positive? && b.positive?) || (a.negative? && b.negative?) + # Auto-exclude stale pending transactions (>8 days old with no matching posted version) + # Prevents orphaned pending transactions from affecting budgets indefinitely + def exclude_and_track_stale_pending(account) + excluded_count = Entry.auto_exclude_stale_pending(account: account) + return unless excluded_count > 0 + + stats["stale_pending_excluded"] = stats.fetch("stale_pending_excluded", 0) + excluded_count + stats["stale_pending_details"] ||= [] + stats["stale_pending_details"] << { + "account_name" => account.name, + "account_id" => account.id, + "count" => excluded_count + } + stats["stale_pending_details"] = stats["stale_pending_details"].last(50) + rescue => e + Rails.logger.warn("SimpleFin: stale pending cleanup failed for account #{account.id}: #{e.class} - #{e.message}") + record_reconciliation_error("stale_pending_cleanup", account, e) + end + + # Track stale pending transactions that couldn't be matched (for user awareness) + # These are >8 days old, still pending, and have no duplicate suggestion + def track_stale_unmatched_pending(account) + stale_unmatched = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(excluded: false) + .where("entries.date < ?", 8.days.ago.to_date) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + .where(<<~SQL.squish) + transactions.extra -> 'potential_posted_match' IS NULL + SQL + .count + + return unless stale_unmatched > 0 + + stats["stale_unmatched_pending"] = stats.fetch("stale_unmatched_pending", 0) + stale_unmatched + stats["stale_unmatched_details"] ||= [] + stats["stale_unmatched_details"] << { + "account_name" => account.name, + "account_id" => account.id, + "count" => stale_unmatched + } + stats["stale_unmatched_details"] = stats["stale_unmatched_details"].last(50) + rescue => e + Rails.logger.warn("SimpleFin: stale unmatched tracking failed for account #{account.id}: #{e.class} - #{e.message}") + record_reconciliation_error("stale_unmatched_tracking", account, e) + end + + # Record reconciliation errors to sync_stats for UI visibility + def record_reconciliation_error(context, account, error) + stats["reconciliation_errors"] ||= [] + stats["reconciliation_errors"] << { + "context" => context, + "account_id" => account.id, + "account_name" => account.name, + "error" => "#{error.class}: #{error.message}" + } + stats["reconciliation_errors"] = stats["reconciliation_errors"].last(20) end end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index dd8eb3064..70ca49da0 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -19,6 +19,22 @@ class Transaction < ApplicationRecord one_time: "one_time" # A one-time expense/income, excluded from budget analytics } + # 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, -> { + where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + } + + scope :excluding_pending, -> { + where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + SQL + } + # Overarching grouping method for all transfer-type transactions def transfer? funds_movement? || cc_payment? || loan_payment? @@ -42,7 +58,85 @@ class Transaction < ApplicationRecord false 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 + + 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 + # This DELETES the pending entry since the posted version is canonical + def merge_with_duplicate! + return false unless has_potential_duplicate? + + posted_entry = potential_duplicate_entry + return false unless posted_entry + + pending_entry_id = entry.id + pending_entry_name = entry.name + + # Delete this pending entry completely (no need to keep it around) + entry.destroy! + + Rails.logger.info("User merged pending entry #{pending_entry_id} (#{pending_entry_name}) with posted entry #{posted_entry.id}") + true + 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 + private + + 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) diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 8dc3efcb5..6c401e0f0 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -6,6 +6,7 @@ class Transaction::Search attribute :amount, :string attribute :amount_operator, :string attribute :types, array: true + attribute :status, array: true attribute :accounts, array: true attribute :account_ids, array: true attribute :start_date, :string @@ -30,6 +31,7 @@ class Transaction::Search query = apply_active_accounts_filter(query, active_accounts_only) query = apply_category_filter(query, categories) query = apply_type_filter(query, types) + query = apply_status_filter(query, status) query = apply_merchant_filter(query, merchants) query = apply_tag_filter(query, tags) query = EntrySearch.apply_search_filter(query, search) @@ -153,4 +155,28 @@ class Transaction::Search return query unless tags.present? query.joins(:tags).where(tags: { name: tags }) end + + def apply_status_filter(query, statuses) + return query unless statuses.present? + return query if statuses.uniq.sort == [ "confirmed", "pending" ] # Both selected = no filter + + pending_condition = <<~SQL.squish + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + confirmed_condition = <<~SQL.squish + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + SQL + + case statuses.sort + when [ "pending" ] + query.where(pending_condition) + when [ "confirmed" ] + query.where(confirmed_condition) + else + query + end + end end diff --git a/app/views/settings/hostings/_sync_settings.html.erb b/app/views/settings/hostings/_sync_settings.html.erb new file mode 100644 index 000000000..0846e039d --- /dev/null +++ b/app/views/settings/hostings/_sync_settings.html.erb @@ -0,0 +1,30 @@ +<% env_configured = ENV["SIMPLEFIN_INCLUDE_PENDING"].present? || ENV["PLAID_INCLUDE_PENDING"].present? %> +
+
+
+

<%= t(".include_pending_label") %>

+

<%= t(".include_pending_description") %>

+
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> + <%= form.toggle :syncs_include_pending, + checked: Setting.syncs_include_pending, + disabled: env_configured, + data: { auto_submit_form_target: "auto" } %> + <% end %> +
+ + <% if env_configured %> +
+
+ <%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %> +

+ <%= t(".env_configured_message") %> +

+
+
+ <% end %> +
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 0d67f4436..00b60c823 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -16,6 +16,9 @@ <% end %>
<% end %> +<%= settings_section title: t(".sync_settings") do %> + <%= render "settings/hostings/sync_settings" %> +<% end %> <%= settings_section title: t(".invites") do %> <%= render "settings/hostings/invite_code_settings" %> <% end %> diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index db3a0b731..530841a2b 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -104,6 +104,25 @@ <%= icon "alert-circle", size: "sm", color: "warning" %> <%= tag.span stale_status[:message], class: "text-sm" %> + <% elsif (pending_status = simplefin_item.stale_pending_status)[:count] > 0 %> +
+
+ <%= icon "clock", size: "sm", color: "secondary" %> + <%= tag.span pending_status[:message], class: "text-sm" %> + <%= t(".stale_pending_note") %> +
+ <% if pending_status[:accounts]&.any? %> +
+ <%= t(".stale_pending_accounts", accounts: pending_status[:accounts].join(", ")) %> +
+ <% end %> +
+ <% elsif (reconciled_status = simplefin_item.last_sync_reconciled_status)[:count] > 0 %> +
+ <%= icon "check-circle", size: "sm", color: "success" %> + <%= tag.span reconciled_status[:message], class: "text-sm" %> + <%= t(".reconciled_details_note") %> +
<% elsif simplefin_item.rate_limited_message.present? %>
<%= icon "clock", size: "sm", color: "warning" %> @@ -117,7 +136,7 @@ <% elsif duplicate_only_errors %>
<%= icon "info", size: "sm" %> - <%= tag.span "Some accounts were skipped as duplicates — use ‘Link existing accounts’ to merge.", class: "text-secondary" %> + <%= tag.span t(".duplicate_accounts_skipped"), class: "text-secondary" %>
<% else %>

diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 6664e0750..f96064401 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -80,12 +80,27 @@ <%# Pending indicator %> <% if transaction.pending? %> - + <%= icon "clock", size: "sm", color: "current" %> - Pending + <%= t("transactions.transaction.pending") %> <% end %> + <%# Potential duplicate indicator - different styling for low vs medium confidence %> + <% if transaction.has_potential_duplicate? %> + <% if transaction.low_confidence_duplicate? %> + + <%= icon "help-circle", size: "sm", color: "current" %> + <%= t("transactions.transaction.review_recommended") %> + + <% else %> + + <%= icon "alert-triangle", size: "sm", color: "current" %> + <%= t("transactions.transaction.possible_duplicate") %> + + <% end %> + <% end %> + <% if transaction.transfer.present? %> <%= render "transactions/transfer_match", transaction: transaction %> <% end %> diff --git a/app/views/transactions/searches/filters/_badge.html.erb b/app/views/transactions/searches/filters/_badge.html.erb index e71bf9ee5..a9276ce8b 100644 --- a/app/views/transactions/searches/filters/_badge.html.erb +++ b/app/views/transactions/searches/filters/_badge.html.erb @@ -35,6 +35,11 @@ end %>">

<%= t(".#{param_value.downcase}") %>

+ <% elsif param_key == "status" %> +
+ <%= icon(param_value.downcase == "pending" ? "clock" : "check", size: "sm") %> +

<%= t(".#{param_value.downcase}") %>

+
<% else %>

<%= param_value %>

diff --git a/app/views/transactions/searches/filters/_status_filter.html.erb b/app/views/transactions/searches/filters/_status_filter.html.erb new file mode 100644 index 000000000..0e3242fd9 --- /dev/null +++ b/app/views/transactions/searches/filters/_status_filter.html.erb @@ -0,0 +1,26 @@ +<%# locals: (form:) %> + +
+
+ <%= form.check_box :status, + { + multiple: true, + checked: @q[:status]&.include?("confirmed"), + class: "checkbox checkbox--light" + }, + "confirmed", + nil %> + <%= form.label :status, t(".confirmed"), value: "confirmed", class: "text-sm text-primary" %> +
+
+ <%= form.check_box :status, + { + multiple: true, + checked: @q[:status]&.include?("pending"), + class: "checkbox checkbox--light" + }, + "pending", + nil %> + <%= form.label :status, t(".pending"), value: "pending", class: "text-sm text-primary" %> +
+
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 1d040c953..029d1f183 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -4,6 +4,47 @@ <% end %> <% dialog.with_body do %> + <%# Potential duplicate alert %> + <% if @entry.transaction.has_potential_duplicate? %> + <% potential_match = @entry.transaction.potential_duplicate_entry %> + <% if potential_match %> +
+
+ <%= icon "alert-triangle", size: "md", color: "warning" %> +
+

<%= t("transactions.show.potential_duplicate_title") %>

+

<%= t("transactions.show.potential_duplicate_description") %>

+ +
+
+
+

<%= potential_match.name %>

+

<%= potential_match.date.strftime("%b %d, %Y") %> • <%= potential_match.account.name %>

+
+

+ <%= format_money(-potential_match.amount_money) %> +

+
+
+ +
+ <%= button_to t("transactions.show.merge_duplicate"), + merge_duplicate_transaction_path(@entry.transaction), + method: :post, + class: "btn btn--primary btn--sm", + data: { turbo_frame: "_top" } %> + <%= button_to t("transactions.show.keep_both"), + dismiss_duplicate_transaction_path(@entry.transaction), + method: :post, + class: "btn btn--outline btn--sm", + data: { turbo_frame: "_top" } %> +
+
+
+
+ <% end %> + <% end %> + <% dialog.with_section(title: t(".overview"), open: true) do %>
<%= styled_form_with model: @entry, diff --git a/config/initializers/plaid_config.rb b/config/initializers/plaid_config.rb index e69fcbc12..a555a3dbc 100644 --- a/config/initializers/plaid_config.rb +++ b/config/initializers/plaid_config.rb @@ -4,4 +4,13 @@ Rails.application.configure do config.plaid = nil config.plaid_eu = nil + + # Plaid pending transaction settings (mirrors SimpleFIN config pattern) + falsy = %w[0 false no off] + config.x.plaid ||= ActiveSupport::OrderedOptions.new + # Default to true - fetch pending transactions for display with "Pending" badge + # and reconciliation when posted versions arrive (Plaid provides pending_transaction_id for reliable linking) + # Set PLAID_INCLUDE_PENDING=0 to disable if user prefers not to see pending transactions + pending_env = ENV["PLAID_INCLUDE_PENDING"].to_s.strip.downcase + config.x.plaid.include_pending = pending_env.blank? ? true : !falsy.include?(pending_env) end diff --git a/config/initializers/simplefin.rb b/config/initializers/simplefin.rb index 07de9aa77..450bcdd77 100644 --- a/config/initializers/simplefin.rb +++ b/config/initializers/simplefin.rb @@ -1,7 +1,15 @@ Rails.application.configure do truthy = %w[1 true yes on] + falsy = %w[0 false no off] config.x.simplefin ||= ActiveSupport::OrderedOptions.new - config.x.simplefin.include_pending = truthy.include?(ENV["SIMPLEFIN_INCLUDE_PENDING"].to_s.strip.downcase) + # Default to true - always fetch pending transactions so they can be: + # - Displayed with a "Pending" badge + # - Excluded from budgets (but included in net worth) + # - Reconciled when posted versions arrive (avoiding duplicates) + # - Auto-excluded after 8 days if they remain stale + # Set SIMPLEFIN_INCLUDE_PENDING=0 to disable if a bank's integration causes issues + pending_env = ENV["SIMPLEFIN_INCLUDE_PENDING"].to_s.strip.downcase + config.x.simplefin.include_pending = pending_env.blank? ? true : !falsy.include?(pending_env) config.x.simplefin.debug_raw = truthy.include?(ENV["SIMPLEFIN_DEBUG_RAW"].to_s.strip.downcase) end diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 688ca8459..8ed337ef4 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -50,15 +50,19 @@ en: activity: amount: Amount balance: Balance + confirmed: Confirmed date: Date entries: entries entry: entry + filter: Filter new: New new_balance: New balance new_transaction: New transaction no_entries: No entries found + pending: Pending search: placeholder: Search entries by name + status: Status title: Activity chart: balance: Balance diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml index 296d151d6..6e5d198f9 100644 --- a/config/locales/views/components/en.yml +++ b/config/locales/views/components/en.yml @@ -24,6 +24,28 @@ en: rate_limited: "Rate limited %{time_ago}" recently: recently errors: "Errors: %{count}" + pending_reconciled: + one: "%{count} duplicate pending transaction reconciled" + other: "%{count} duplicate pending transactions reconciled" + view_reconciled: View reconciled transactions + duplicate_suggestions: + one: "%{count} possible duplicate needs review" + other: "%{count} possible duplicates need review" + view_duplicate_suggestions: View suggested duplicates + stale_pending: + one: "%{count} stale pending transaction (excluded from budgets)" + other: "%{count} stale pending transactions (excluded from budgets)" + view_stale_pending: View affected accounts + stale_pending_count: + one: "%{count} transaction" + other: "%{count} transactions" + stale_unmatched: + one: "%{count} pending transaction needs manual review" + other: "%{count} pending transactions need manual review" + view_stale_unmatched: View transactions needing review + stale_unmatched_count: + one: "%{count} transaction" + other: "%{count} transactions" data_warnings: "Data warnings: %{count}" notices: "Notices: %{count}" view_data_quality: View data quality details diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 81d73148c..f824501ed 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -17,6 +17,7 @@ en: show: general: General Settings financial_data_providers: Financial Data Providers + sync_settings: Sync Settings invites: Invite Codes title: Self-Hosting danger_zone: Danger Zone @@ -77,3 +78,7 @@ en: clear_cache: cache_cleared: Data cache has been cleared. This may take a few moments to complete. not_authorized: You are not authorized to perform this action + sync_settings: + include_pending_label: Include pending transactions + include_pending_description: When enabled, pending (uncleared) transactions will be imported and automatically reconciled when they post. Disable if your bank provides unreliable pending data. + env_configured_message: This setting is disabled because a provider environment variable (SIMPLEFIN_INCLUDE_PENDING or PLAID_INCLUDE_PENDING) is set. Remove it to enable this setting. diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index a84c335e3..10f83be0f 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -70,6 +70,10 @@ en: status_with_summary: "Last synced %{timestamp} ago • %{summary}" syncing: Syncing... update: Update + stale_pending_note: "(excluded from budgets)" + stale_pending_accounts: "in: %{accounts}" + reconciled_details_note: "(see sync summary for details)" + duplicate_accounts_skipped: "Some accounts were skipped as duplicates — use 'Link existing accounts' to merge." select_existing_account: title: "Link %{account_name} to SimpleFIN" description: Select a SimpleFIN account to link to your existing account @@ -86,3 +90,11 @@ en: errors: only_manual: Only manual accounts can be linked invalid_simplefin_account: Invalid SimpleFIN account selected + reconciled_status: + message: + one: "%{count} duplicate pending transaction reconciled" + other: "%{count} duplicate pending transactions reconciled" + stale_pending_status: + message: + one: "%{count} pending transaction older than %{days} days" + other: "%{count} pending transactions older than %{days} days" diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 4572ed8c6..717d146b1 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -44,6 +44,23 @@ en: 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 + possible_duplicate: Duplicate? + potential_duplicate_tooltip: This may be a duplicate of another transaction + review_recommended: Review + review_recommended_tooltip: Large amount difference — review recommended to check if this is a duplicate + merge_duplicate: + success: Transactions merged successfully + failure: Could not merge transactions + dismiss_duplicate: + success: Kept as separate transactions + failure: Could not dismiss duplicate suggestion header: edit_categories: Edit categories edit_imports: Edit imports @@ -55,6 +72,16 @@ en: transactions: transactions import: Import toggle_recurring_section: Toggle upcoming recurring transactions + search: + filters: + account: Account + date: Date + type: Type + status: Status + amount: Amount + category: Category + tag: Tag + merchant: Merchant searches: filters: amount_filter: @@ -68,10 +95,15 @@ en: on_or_after: on or after %{date} on_or_before: on or before %{date} transfer: Transfer + confirmed: Confirmed + pending: Pending type_filter: expense: Expense income: Income transfer: Transfer + status_filter: + confirmed: Confirmed + pending: Pending menu: account_filter: Account amount_filter: Amount @@ -81,6 +113,7 @@ en: clear_filters: Clear filters date_filter: Date merchant_filter: Merchant + status_filter: Status tag_filter: Tag type_filter: Type search: diff --git a/config/routes.rb b/config/routes.rb index 615cd1314..f6f6fdd66 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -191,6 +191,8 @@ Rails.application.routes.draw do member do post :mark_as_recurring + post :merge_duplicate + post :dismiss_duplicate end end diff --git a/lib/tasks/simplefin_pending_cleanup.rake b/lib/tasks/simplefin_pending_cleanup.rake new file mode 100644 index 000000000..147adda8b --- /dev/null +++ b/lib/tasks/simplefin_pending_cleanup.rake @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +namespace :simplefin do + desc "Find and optionally remove duplicate pending transactions that have matching posted versions" + task pending_cleanup: :environment do + dry_run = ENV["DRY_RUN"] != "false" + date_window = (ENV["DATE_WINDOW"] || 8).to_i + + puts "SimpleFIN Pending Transaction Cleanup" + puts "======================================" + puts "Mode: #{dry_run ? 'DRY RUN (no changes)' : 'LIVE (will delete duplicates)'}" + puts "Date window: #{date_window} days" + puts "" + + # Find all pending SimpleFIN transactions + pending_entries = Entry.joins( + "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'" + ).where(source: "simplefin") + .where("transactions.extra -> 'simplefin' ->> 'pending' = ?", "true") + .includes(:account) + + puts "Found #{pending_entries.count} pending SimpleFIN transactions" + puts "" + + duplicates_found = 0 + duplicates_to_delete = [] + + pending_entries.find_each do |pending_entry| + # Look for a matching posted transaction + posted_match = Entry.joins( + "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'" + ).where(account_id: pending_entry.account_id) + .where(source: "simplefin") + .where(amount: pending_entry.amount) + .where(currency: pending_entry.currency) + .where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending + .where.not(id: pending_entry.id) + .where("transactions.extra -> 'simplefin' ->> 'pending' != ? OR transactions.extra -> 'simplefin' ->> 'pending' IS NULL", "true") + .first + + if posted_match + duplicates_found += 1 + duplicates_to_delete << pending_entry + + puts "DUPLICATE FOUND:" + puts " Pending: ID=#{pending_entry.id} | #{pending_entry.date} | #{pending_entry.name} | #{pending_entry.amount} #{pending_entry.currency}" + puts " Posted: ID=#{posted_match.id} | #{posted_match.date} | #{posted_match.name} | #{posted_match.amount} #{posted_match.currency}" + puts " Account: #{pending_entry.account.name}" + puts "" + end + end + + puts "======================================" + puts "Summary: #{duplicates_found} duplicate pending transactions found" + puts "" + + if duplicates_found > 0 + if dry_run + puts "To delete these duplicates, run:" + puts " rails simplefin:pending_cleanup DRY_RUN=false" + puts "" + puts "To adjust the date matching window (default 8 days):" + puts " rails simplefin:pending_cleanup DATE_WINDOW=14" + else + print "Deleting #{duplicates_to_delete.count} duplicate pending entries... " + Entry.where(id: duplicates_to_delete.map(&:id)).destroy_all + puts "Done!" + end + else + puts "No duplicates found. Nothing to clean up." + end + end + + desc "Un-exclude pending transactions that were wrongly matched (fixes direction bug)" + task pending_restore: :environment do + dry_run = ENV["DRY_RUN"] != "false" + date_window = (ENV["DATE_WINDOW"] || 8).to_i + + puts "Restore Wrongly Excluded Pending Transactions" + puts "==============================================" + puts "Mode: #{dry_run ? 'DRY RUN (no changes)' : 'LIVE (will restore)'}" + puts "Date window: #{date_window} days (forward only)" + puts "" + + # Find all EXCLUDED pending transactions (these may have been wrongly excluded) + excluded_pending = Entry.joins( + "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'" + ).where(excluded: true) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + puts "Found #{excluded_pending.count} excluded pending transactions to evaluate" + puts "" + + to_restore = [] + + excluded_pending.includes(:account).find_each do |pending_entry| + # Check if there's a VALID posted match using CORRECT logic (forward-only dates) + # Posted date must be ON or AFTER pending date + valid_match = pending_entry.account.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)) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE + SQL + .exists? + + unless valid_match + to_restore << pending_entry + puts "SHOULD RESTORE (no valid match):" + puts " ID=#{pending_entry.id} | #{pending_entry.date} | #{pending_entry.name} | #{pending_entry.amount} #{pending_entry.currency}" + puts " Account: #{pending_entry.account.name}" + puts "" + end + end + + puts "==============================================" + puts "Summary: #{to_restore.count} transactions should be restored" + puts "" + + if to_restore.any? + if dry_run + puts "To restore these transactions, run:" + puts " rails simplefin:pending_restore DRY_RUN=false" + else + Entry.where(id: to_restore.map(&:id)).update_all(excluded: false) + puts "Restored #{to_restore.count} transactions!" + end + else + puts "No wrongly excluded transactions found." + end + end + + desc "List all pending SimpleFIN transactions (for review)" + task pending_list: :environment do + pending_entries = Entry.joins( + "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'" + ).where(source: "simplefin") + .where("transactions.extra -> 'simplefin' ->> 'pending' = ?", "true") + .includes(:account) + .order(date: :desc) + + puts "All Pending SimpleFIN Transactions" + puts "===================================" + puts "Total: #{pending_entries.count}" + puts "" + + pending_entries.find_each do |entry| + puts "ID=#{entry.id} | #{entry.date} | #{entry.name.truncate(40)} | #{entry.amount} #{entry.currency} | Account: #{entry.account.name}" + end + end +end diff --git a/test/models/account/provider_import_adapter_test.rb b/test/models/account/provider_import_adapter_test.rb index 3651b3fde..9fe7df7d8 100644 --- a/test/models/account/provider_import_adapter_test.rb +++ b/test/models/account/provider_import_adapter_test.rb @@ -785,4 +785,482 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase assert_nil newer_entry.reload.external_id end end + + # ============================================================================ + # Pending→Posted Transaction Reconciliation Tests + # ============================================================================ + + test "reconciles pending transaction when posted version arrives with different external_id" do + # Simulate SimpleFIN giving different IDs for pending vs posted transactions + # First, import a pending transaction + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_abc", + amount: 99.99, + currency: "USD", + date: Date.today - 2.days, + name: "Coffee Shop", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + assert pending_entry.transaction.pending?, "Entry should be marked pending" + original_id = pending_entry.id + + # Now import the posted version with a DIFFERENT external_id + # This should claim the pending entry, not create a duplicate + assert_no_difference "@account.entries.count" do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_xyz", + amount: 99.99, + currency: "USD", + date: Date.today, + name: "Coffee Shop - Posted", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + # Should be the same entry, now with updated external_id + assert_equal original_id, posted_entry.id + assert_equal "simplefin_posted_xyz", posted_entry.external_id + assert_not posted_entry.transaction.pending?, "Entry should no longer be pending" + end + end + + test "does not reconcile when posted transaction has same external_id as pending" do + # When external_id matches, normal dedup should handle it + pending_entry = @adapter.import_transaction( + external_id: "simplefin_same_id", + amount: 50.00, + currency: "USD", + date: Date.today - 1.day, + name: "Gas Station", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import posted version with SAME external_id + assert_no_difference "@account.entries.count" do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_same_id", + amount: 50.00, + currency: "USD", + date: Date.today, + name: "Gas Station - Posted", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + assert_equal pending_entry.id, posted_entry.id + assert_not posted_entry.transaction.pending? + end + end + + test "fuzzy amount match creates suggestion instead of auto-claiming" do + # Import pending transaction (pre-tip authorization) + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_amount_test", + amount: 100.00, + currency: "USD", + date: Date.today - 1.day, + name: "Restaurant", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import posted with tip added - should NOT auto-claim, but should store suggestion + # Fuzzy matches now create suggestions for user review instead of auto-reconciling + assert_difference "@account.entries.count", 1 do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_amount_test", + amount: 105.00, # 5% tip added - within 25% tolerance + currency: "USD", + date: Date.today, + name: "Restaurant", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + # Should be a NEW entry (not claimed) + assert_not_equal pending_entry.id, posted_entry.id + assert_equal "simplefin_posted_amount_test", posted_entry.external_id + + # The PENDING entry should now have a potential_posted_match suggestion + pending_entry.reload + assert pending_entry.transaction.has_potential_duplicate? + assert_equal posted_entry.id, pending_entry.transaction.potential_duplicate_entry.id + end + end + + test "does not reconcile pending when amount difference exceeds tolerance" do + # Import pending transaction + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_big_diff", + amount: 50.00, + currency: "USD", + date: Date.today - 1.day, + name: "Store", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import posted with amount >25% different - should NOT match + # $100 posted / 1.25 = $80 minimum pending, but pending is only $50 + assert_difference "@account.entries.count", 1 do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_big_diff", + amount: 100.00, # 100% increase - way outside 25% tolerance + currency: "USD", + date: Date.today, + name: "Store", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + assert_not_equal pending_entry.id, posted_entry.id + end + end + + test "does not reconcile pending when date is outside window" do + # Import pending transaction + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_date_test", + amount: 25.00, + currency: "USD", + date: Date.today - 15.days, # 15 days ago + name: "Subscription", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import posted with date outside 7-day window - should NOT match + assert_difference "@account.entries.count", 1 do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_date_test", + amount: 25.00, + currency: "USD", + date: Date.today, + name: "Subscription", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + assert_not_equal pending_entry.id, posted_entry.id + end + end + + test "reconciles pending within 7 day window" do + # Import pending transaction + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_window_test", + amount: 75.00, + currency: "USD", + date: Date.today - 5.days, + name: "Online Order", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import posted within 7-day window - should match + assert_no_difference "@account.entries.count" do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_window_test", + amount: 75.00, + currency: "USD", + date: Date.today, + name: "Online Order - Posted", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + assert_equal pending_entry.id, posted_entry.id + end + end + + test "does not reconcile pending from different source" do + # Import pending from SimpleFIN + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_source_test", + amount: 30.00, + currency: "USD", + date: Date.today - 1.day, + name: "Pharmacy", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import from different source (plaid) - should NOT match SimpleFIN pending + assert_difference "@account.entries.count", 1 do + plaid_entry = @adapter.import_transaction( + external_id: "plaid_posted_source_test", + amount: 30.00, + currency: "USD", + date: Date.today, + name: "Pharmacy", + source: "plaid", + extra: { "plaid" => { "pending" => false } } + ) + + assert_not_equal pending_entry.id, plaid_entry.id + end + end + + test "does not reconcile when incoming transaction is also pending" do + # Import first pending transaction + pending_entry1 = @adapter.import_transaction( + external_id: "simplefin_pending_1", + amount: 45.00, + currency: "USD", + date: Date.today - 1.day, + name: "Store", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import another pending transaction with different ID - should NOT match + assert_difference "@account.entries.count", 1 do + pending_entry2 = @adapter.import_transaction( + external_id: "simplefin_pending_2", + amount: 45.00, + currency: "USD", + date: Date.today, + name: "Store", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + assert_not_equal pending_entry1.id, pending_entry2.id + end + end + + test "reconciles most recent pending when multiple exist" do + # Create two pending transactions with same amount + older_pending = @adapter.import_transaction( + external_id: "simplefin_older_pending", + amount: 60.00, + currency: "USD", + date: Date.today - 5.days, + name: "Recurring Payment - Old", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + newer_pending = @adapter.import_transaction( + external_id: "simplefin_newer_pending", + amount: 60.00, + currency: "USD", + date: Date.today - 1.day, + name: "Recurring Payment - New", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Import posted - should match the most recent pending (by date) + assert_no_difference "@account.entries.count" do + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_recurring", + amount: 60.00, + currency: "USD", + date: Date.today, + name: "Recurring Payment - Posted", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + # Should match the newer pending entry + assert_equal newer_pending.id, posted_entry.id + # Older pending should remain untouched + assert_equal "simplefin_older_pending", older_pending.reload.external_id + end + end + + test "find_pending_transaction returns nil when no pending transactions exist" do + # Create a non-pending transaction + @adapter.import_transaction( + external_id: "simplefin_not_pending", + amount: 40.00, + currency: "USD", + date: Date.today - 1.day, + name: "Regular Transaction", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + result = @adapter.find_pending_transaction( + date: Date.today, + amount: 40.00, + currency: "USD", + source: "simplefin" + ) + + assert_nil result + end + + # ============================================================================ + # Critical Direction Fix Tests (CITGO Bug Prevention) + # ============================================================================ + + test "does not match pending transaction that is AFTER the posted date (direction fix)" do + # This is the CITGO bug scenario: + # - Posted transaction on Dec 31 + # - Pending transaction on Jan 8 (AFTER the posted) + # - These should NOT match because pending MUST come BEFORE posted + + # First, import a POSTED transaction on an earlier date + posted_entry = @adapter.import_transaction( + external_id: "simplefin_posted_dec31", + amount: 6.67, + currency: "USD", + date: Date.today - 8.days, # Dec 31 (earlier) + name: "CITGO Gas Station", + source: "simplefin", + extra: { "simplefin" => { "pending" => false } } + ) + + # Now import a PENDING transaction on a LATER date + # This should NOT be matched because the date direction is wrong + assert_difference "@account.entries.count", 1 do + pending_entry = @adapter.import_transaction( + external_id: "simplefin_pending_jan8", + amount: 6.65, # Similar but different amount + currency: "USD", + date: Date.today, # Jan 8 (later) + name: "CITGO Gas Station", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Should be a DIFFERENT entry - not matched to the earlier posted one + assert_not_equal posted_entry.id, pending_entry.id + assert pending_entry.transaction.pending? + end + end + + test "find_pending_transaction only searches backward in time" do + # Create a pending transaction in the FUTURE (after the posted date we'll search from) + # This should NOT be found because pending must be ON or BEFORE posted + future_pending = @adapter.import_transaction( + external_id: "simplefin_future_pending", + amount: 50.00, + currency: "USD", + date: Date.today + 3.days, # Future date + name: "Future Transaction", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Search from today - should NOT find the future pending + result = @adapter.find_pending_transaction( + date: Date.today, + amount: 50.00, + currency: "USD", + source: "simplefin" + ) + + assert_nil result, "Should not find pending transactions that are in the future relative to the posted date" + end + + test "find_pending_transaction finds pending transaction that is before posted date" do + # Create a pending transaction in the PAST (before the posted date) + # This SHOULD be found + past_pending = @adapter.import_transaction( + external_id: "simplefin_past_pending", + amount: 75.00, + currency: "USD", + date: Date.today - 3.days, # 3 days ago + name: "Past Transaction", + source: "simplefin", + extra: { "simplefin" => { "pending" => true } } + ) + + # Search from today - should find the past pending + result = @adapter.find_pending_transaction( + date: Date.today, + amount: 75.00, + currency: "USD", + source: "simplefin" + ) + + assert_equal past_pending.id, result.id + end + + # ============================================================================ + # Plaid pending_transaction_id Tests + # ============================================================================ + + test "reconciles pending via Plaid pending_transaction_id" do + # Import a pending transaction + pending_entry = @adapter.import_transaction( + external_id: "plaid_pending_abc", + amount: 42.00, + currency: "USD", + date: Date.today - 2.days, + name: "Coffee Shop", + source: "plaid", + extra: { "plaid" => { "pending" => true } } + ) + + # Import posted with pending_transaction_id linking to the pending + assert_no_difference "@account.entries.count" do + posted_entry = @adapter.import_transaction( + external_id: "plaid_posted_xyz", + amount: 42.00, + currency: "USD", + date: Date.today, + name: "Coffee Shop", + source: "plaid", + pending_transaction_id: "plaid_pending_abc", # Links to pending + extra: { "plaid" => { "pending" => false, "pending_transaction_id" => "plaid_pending_abc" } } + ) + + # Should claim the pending entry + assert_equal pending_entry.id, posted_entry.id + assert_equal "plaid_posted_xyz", posted_entry.external_id + assert_not posted_entry.transaction.pending? + end + end + + test "Plaid pending_transaction_id takes priority over amount matching" do + # Create TWO pending transactions with same amount + pending1 = @adapter.import_transaction( + external_id: "plaid_pending_1", + amount: 25.00, + currency: "USD", + date: Date.today - 1.day, + name: "Store A", + source: "plaid", + extra: { "plaid" => { "pending" => true } } + ) + + pending2 = @adapter.import_transaction( + external_id: "plaid_pending_2", + amount: 25.00, + currency: "USD", + date: Date.today - 1.day, + name: "Store B", + source: "plaid", + extra: { "plaid" => { "pending" => true } } + ) + + # Import posted that explicitly links to pending2 via pending_transaction_id + assert_no_difference "@account.entries.count" do + posted_entry = @adapter.import_transaction( + external_id: "plaid_posted_linked", + amount: 25.00, + currency: "USD", + date: Date.today, + name: "Store B", + source: "plaid", + pending_transaction_id: "plaid_pending_2", # Explicitly links to pending2 + extra: { "plaid" => { "pending" => false } } + ) + + # Should claim pending2 specifically (not pending1) + assert_equal pending2.id, posted_entry.id + assert_equal "plaid_posted_linked", posted_entry.external_id + end + + # pending1 should still exist as pending + pending1.reload + assert_equal "plaid_pending_1", pending1.external_id + end end diff --git a/test/models/simplefin_entry/processor_test.rb b/test/models/simplefin_entry/processor_test.rb index 2717f6bb5..72a57ee0a 100644 --- a/test/models/simplefin_entry/processor_test.rb +++ b/test/models/simplefin_entry/processor_test.rb @@ -29,8 +29,8 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase payee: "Pizza Hut", description: "Order #1234", memo: "Carryout", - posted: Date.today.to_s, - transacted_at: (Date.today - 1).to_s, + posted: Date.current.to_s, + transacted_at: (Date.current - 1).to_s, extra: { category: "restaurants", check_number: nil } } @@ -64,7 +64,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase description: "Latte", memo: "Morning run", posted: nil, - transacted_at: (Date.today - 3).to_s + transacted_at: (Date.current - 3).to_s } SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process @@ -77,8 +77,8 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase test "captures FX metadata when tx currency differs from account currency" do # Account is USD from setup; use EUR for tx - t_date = (Date.today - 5) - p_date = Date.today + t_date = (Date.current - 5) + p_date = Date.current tx = { id: "tx_fx_1", @@ -106,8 +106,8 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase payee: "Test Store", description: "Auth", memo: "", - posted: Date.today.to_s, # provider says pending=true should still flag - transacted_at: (Date.today - 1).to_s, + posted: Date.current.to_s, # provider says pending=true should still flag + transacted_at: (Date.current - 1).to_s, pending: true } @@ -120,7 +120,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase test "posted==0 treated as missing, entry uses transacted_at date and flags pending" do # Simulate provider sending epoch-like zeros for posted and an integer transacted_at - t_epoch = (Date.today - 2).to_time.to_i + t_epoch = (Date.current - 2).to_time.to_i tx = { id: "tx_pending_zero_posted_1", amount: "-6.48", @@ -141,4 +141,26 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase sf = entry.transaction.extra.fetch("simplefin") assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true" end + + test "infers pending when posted is explicitly 0 and transacted_at present (no explicit pending flag)" do + # Some SimpleFIN banks indicate pending by sending posted=0 + transacted_at, without pending flag + t_epoch = (Date.current - 1).to_time.to_i + tx = { + id: "tx_inferred_pending_1", + amount: "-15.00", + currency: "USD", + payee: "Gas Station", + description: "Fuel", + memo: "", + posted: 0, + transacted_at: t_epoch + # Note: NO pending flag set + } + + SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process + + entry = @account.entries.find_by!(external_id: "simplefin_tx_inferred_pending_1", source: "simplefin") + sf = entry.transaction.extra.fetch("simplefin") + assert_equal true, sf["pending"], "expected pending to be inferred from posted=0 + transacted_at present" + end end