mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
* Add pending transaction handling and duplicate reconciliation logic - Implemented logic to exclude pending transactions from budgets and analytics calculations. - Introduced mechanisms for reconciling pending transactions with posted versions. - Added duplicate detection with support for merging or dismissing matches. - Updated transaction search filters to include a `status_filter` for pending/confirmed transactions. - Introduced UI elements for reviewing and resolving duplicates. - Enhanced `ProviderSyncSummary` with stats for reconciled and stale pending transactions. * Refactor translation handling and enhance transaction and sync logic - Moved hardcoded strings to locale files for improved translation support. - Refined styling for duplicate transaction indicators and sync summaries. - Improved logic for excluding stale pending transactions and updating timestamps on batch exclusion. - Added unique IDs to status filters for better element targeting in UI. - Optimized database queries to avoid N+1 issues in stale pending calculations. * Add sync settings and enhance pending transaction handling - Introduced a new "Sync Settings" section in hosting settings with UI to toggle inclusion of pending transactions. - Updated handling of pending transactions with improved inference logic for `posted=0` and `transacted_at` in processors. - Added priority order for pending transaction inclusion: explicit argument > environment variable > runtime configurable setting. - Refactored settings and controllers to store updated sync preferences. * Refactor sync settings and pending transaction reconciliation - Extracted logic for pending transaction reconciliation, stale exclusion, and unmatched tracking into dedicated methods for better maintainability. - Updated sync settings to infer defaults from multiple provider environment variables (`SIMPLEFIN_INCLUDE_PENDING`, `PLAID_INCLUDE_PENDING`). - Refined UI and messaging to handle multi-provider configurations in sync settings. # Conflicts: # app/models/simplefin_item/importer.rb * Debounce transaction reconciliation during imports - Added per-run reconciliation debouncing to prevent repeated scans for the same account during chunked history imports. - Trimmed size of reconciliation stats to retain recent details only. - Introduced error tracking for reconciliation steps to improve UI visibility of issues. * Apply ABS() in pending transaction queries and improve error handling - Updated pending transaction logic to use ABS() for consistent handling of negative amounts. - Adjusted amount bounds calculations to ensure accuracy for both positive and negative values. - Refined exception handling in `merge_duplicate` to log failures and update user alert. - Replaced `Date.today` with `Date.current` in tests to ensure timezone consistency. - Minor optimization to avoid COUNT queries by loading limited records directly. * Improve error handling in duplicate suggestion and dismissal logic - Added exception handling for `store_duplicate_suggestion` to log failures and prevent crashes during fuzzy/low-confidence matches. - Enhanced `dismiss_duplicate` action to handle `ActiveRecord::RecordInvalid` and display appropriate user alerts. --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
283 lines
8.9 KiB
Ruby
283 lines
8.9 KiB
Ruby
class TransactionsController < ApplicationController
|
|
include EntryableResource
|
|
|
|
before_action :store_params!, only: :index
|
|
|
|
def new
|
|
super
|
|
@income_categories = Current.family.categories.incomes.alphabetically
|
|
@expense_categories = Current.family.categories.expenses.alphabetically
|
|
end
|
|
|
|
def index
|
|
@q = search_params
|
|
@search = Transaction::Search.new(Current.family, filters: @q)
|
|
|
|
base_scope = @search.transactions_scope
|
|
.reverse_chronological
|
|
.includes(
|
|
{ entry: :account },
|
|
:category, :merchant, :tags,
|
|
:transfer_as_inflow, :transfer_as_outflow
|
|
)
|
|
|
|
@pagy, @transactions = pagy(base_scope, limit: per_page)
|
|
|
|
# Load projected recurring transactions for next month
|
|
@projected_recurring = Current.family.recurring_transactions
|
|
.active
|
|
.where("next_expected_date <= ? AND next_expected_date >= ?",
|
|
1.month.from_now.to_date,
|
|
Date.current)
|
|
.includes(:merchant)
|
|
end
|
|
|
|
def clear_filter
|
|
updated_params = {
|
|
"q" => search_params,
|
|
"page" => params[:page],
|
|
"per_page" => params[:per_page]
|
|
}
|
|
|
|
q_params = updated_params["q"] || {}
|
|
|
|
param_key = params[:param_key]
|
|
param_value = params[:param_value]
|
|
|
|
if q_params[param_key].is_a?(Array)
|
|
q_params[param_key].delete(param_value)
|
|
q_params.delete(param_key) if q_params[param_key].empty?
|
|
else
|
|
q_params.delete(param_key)
|
|
end
|
|
|
|
updated_params["q"] = q_params.presence
|
|
|
|
# Add flag to indicate filters were explicitly cleared
|
|
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
|
|
|
|
Current.session.update!(prev_transaction_page_params: updated_params)
|
|
|
|
redirect_to transactions_path(updated_params)
|
|
end
|
|
|
|
def create
|
|
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
|
@entry = account.entries.new(entry_params)
|
|
|
|
if @entry.save
|
|
@entry.sync_account_later
|
|
@entry.lock_saved_attributes!
|
|
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
|
|
|
flash[:notice] = "Transaction created"
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_back_or_to account_path(@entry.account) }
|
|
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
|
|
end
|
|
else
|
|
render :new, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def update
|
|
if @entry.update(entry_params)
|
|
transaction = @entry.transaction
|
|
|
|
if needs_rule_notification?(transaction)
|
|
flash[:cta] = {
|
|
type: "category_rule",
|
|
category_id: transaction.category_id,
|
|
category_name: transaction.category.name
|
|
}
|
|
end
|
|
|
|
@entry.sync_account_later
|
|
@entry.lock_saved_attributes!
|
|
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
|
|
format.turbo_stream do
|
|
render turbo_stream: [
|
|
turbo_stream.replace(
|
|
dom_id(@entry, :header),
|
|
partial: "transactions/header",
|
|
locals: { entry: @entry }
|
|
),
|
|
turbo_stream.replace(@entry),
|
|
*flash_notification_stream_items
|
|
]
|
|
end
|
|
end
|
|
else
|
|
render :show, status: :unprocessable_entity
|
|
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])
|
|
|
|
# Check if a recurring transaction already exists for this pattern
|
|
existing = Current.family.recurring_transactions.find_by(
|
|
merchant_id: transaction.merchant_id,
|
|
name: transaction.merchant_id.present? ? nil : transaction.entry.name,
|
|
currency: transaction.entry.currency,
|
|
manual: true
|
|
)
|
|
|
|
if existing
|
|
flash[:alert] = t("recurring_transactions.already_exists")
|
|
redirect_back_or_to transactions_path
|
|
return
|
|
end
|
|
|
|
begin
|
|
recurring_transaction = RecurringTransaction.create_from_transaction(transaction)
|
|
|
|
respond_to do |format|
|
|
format.html do
|
|
flash[:notice] = t("recurring_transactions.marked_as_recurring")
|
|
redirect_back_or_to transactions_path
|
|
end
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
respond_to do |format|
|
|
format.html do
|
|
flash[:alert] = t("recurring_transactions.creation_failed")
|
|
redirect_back_or_to transactions_path
|
|
end
|
|
end
|
|
rescue StandardError => e
|
|
respond_to do |format|
|
|
format.html do
|
|
flash[:alert] = t("recurring_transactions.unexpected_error")
|
|
redirect_back_or_to transactions_path
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def update_preferences
|
|
Current.user.update_transactions_preferences(preferences_params)
|
|
head :ok
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
|
|
head :unprocessable_entity
|
|
end
|
|
|
|
private
|
|
def per_page
|
|
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
|
|
end
|
|
|
|
def needs_rule_notification?(transaction)
|
|
return false if Current.user.rule_prompts_disabled
|
|
|
|
if Current.user.rule_prompt_dismissed_at.present?
|
|
time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at
|
|
return false if time_since_last_rule_prompt < 1.day
|
|
end
|
|
|
|
transaction.saved_change_to_category_id? && transaction.category_id.present? &&
|
|
transaction.eligible_for_category_rule?
|
|
end
|
|
|
|
def entry_params
|
|
entry_params = params.require(:entry).permit(
|
|
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
|
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
|
|
)
|
|
|
|
nature = entry_params.delete(:nature)
|
|
|
|
if nature.present? && entry_params[:amount].present?
|
|
signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
|
entry_params = entry_params.merge(amount: signed_amount)
|
|
end
|
|
|
|
entry_params
|
|
end
|
|
|
|
def search_params
|
|
cleaned_params = params.fetch(:q, {})
|
|
.permit(
|
|
:start_date, :end_date, :search, :amount,
|
|
:amount_operator, :active_accounts_only,
|
|
accounts: [], account_ids: [],
|
|
categories: [], merchants: [], types: [], tags: [], status: []
|
|
)
|
|
.to_h
|
|
.compact_blank
|
|
|
|
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
|
|
|
|
|
cleaned_params
|
|
end
|
|
|
|
def store_params!
|
|
if should_restore_params?
|
|
params_to_restore = {}
|
|
|
|
params_to_restore[:q] = stored_params["q"].presence || {}
|
|
params_to_restore[:page] = stored_params["page"].presence || 1
|
|
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
|
|
|
|
redirect_to transactions_path(params_to_restore)
|
|
else
|
|
Current.session.update!(
|
|
prev_transaction_page_params: {
|
|
q: search_params,
|
|
page: params[:page],
|
|
per_page: params[:per_page]
|
|
}
|
|
)
|
|
end
|
|
end
|
|
|
|
def should_restore_params?
|
|
request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?)
|
|
end
|
|
|
|
def stored_params
|
|
Current.session.prev_transaction_page_params
|
|
end
|
|
|
|
def preferences_params
|
|
params.require(:preferences).permit(collapsed_sections: {})
|
|
end
|
|
end
|