diff --git a/Gemfile b/Gemfile
index 1adb7b690e3..48f6b8e050d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -25,9 +25,6 @@ gem "turbo-rails"
# Background Jobs
gem "good_job"
-# Search
-gem "ransack", github: "maybe-finance/ransack", branch: "main"
-
# Error logging
gem "stackprof"
gem "sentry-ruby"
diff --git a/Gemfile.lock b/Gemfile.lock
index c845ab3bc0f..ac280841c99 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -5,16 +5,6 @@ GIT
lucide-rails (0.2.0)
railties (>= 4.1.0)
-GIT
- remote: https://github.com/maybe-finance/ransack.git
- revision: dec20edc9ccccac77f5b4b8a1c1a9f20dc58fa04
- branch: main
- specs:
- ransack (4.1.1)
- activerecord (>= 6.1.5)
- activesupport (>= 6.1.5)
- i18n
-
GIT
remote: https://github.com/rails/rails.git
revision: c1f1b14adce5cd373ed63611486eb7a7db73c78c
@@ -494,7 +484,6 @@ DEPENDENCIES
puma (>= 5.0)
rails!
rails-settings-cached
- ransack!
rubocop-rails-omakase
ruby-lsp-rails
selenium-webdriver
diff --git a/app/controllers/transactions/rows_controller.rb b/app/controllers/transactions/rows_controller.rb
new file mode 100644
index 00000000000..159e75a9c78
--- /dev/null
+++ b/app/controllers/transactions/rows_controller.rb
@@ -0,0 +1,22 @@
+class Transactions::RowsController < ApplicationController
+ before_action :set_transaction, only: %i[ show update ]
+
+ def show
+ end
+
+ def update
+ @transaction.update! transaction_params
+
+ redirect_to transaction_row_path(@transaction)
+ end
+
+ private
+
+ def transaction_params
+ params.require(:transaction).permit(:category_id)
+ end
+
+ def set_transaction
+ @transaction = Current.family.transactions.find(params[:id])
+ end
+end
diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index 5208e1e5097..bc12c6772d5 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -4,57 +4,15 @@ class TransactionsController < ApplicationController
before_action :set_transaction, only: %i[ show edit update destroy ]
def index
- search_params = session[ransack_session_key] || params[:q]
- @q = Current.family.transactions.ransack(search_params)
- result = @q.result.order(date: :desc)
- @pagy, @transactions = pagy(result, items: 10)
+ @q = search_params
+ result = Current.family.transactions.search(@q).ordered
+ @pagy, @transactions = pagy(result, items: 50)
+
@totals = {
count: result.count,
income: result.inflows.sum(&:amount_money).abs,
expense: result.outflows.sum(&:amount_money).abs
}
- @filter_list, valid_params = Transaction.build_filter_list(search_params, Current.family)
- session[ransack_session_key] = valid_params
-
- respond_to do |format|
- format.html
- format.turbo_stream
- end
- end
-
- def search
- if params[:clear]
- session.delete(ransack_session_key)
- elsif params[:remove_param]
- current_params = session[ransack_session_key] || {}
- if params[:remove_param] == "date_range"
- updated_params = current_params.except("date_gteq", "date_lteq")
- elsif params[:remove_param_value]
- key_to_remove = params[:remove_param]
- value_to_remove = params[:remove_param_value]
- updated_params = current_params.deep_dup
- updated_params[key_to_remove] = updated_params[key_to_remove] - [ value_to_remove ]
- else
- updated_params = current_params.except(params[:remove_param])
- end
- session[ransack_session_key] = updated_params
- elsif params[:q]
- session[ransack_session_key] = params[:q]
- end
-
- index
-
- respond_to do |format|
- format.html { render :index }
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.replace("transactions_summary", partial: "transactions/summary", locals: { totals: @totals }),
- turbo_stream.replace("transactions_search_form", partial: "transactions/search_form", locals: { q: @q }),
- turbo_stream.replace("transactions_filters", partial: "transactions/filters", locals: { filters: @filter_list }),
- turbo_stream.replace("transactions_list", partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy })
- ]
- end
- end
end
def show
@@ -76,80 +34,28 @@ def create
.find(params[:transaction][:account_id])
.transactions.build(transaction_params.merge(amount: amount))
- respond_to do |format|
- if @transaction.save
- @transaction.account.sync_later(@transaction.date)
- format.html { redirect_to transactions_url, notice: t(".success") }
- else
- format.html { render :new, status: :unprocessable_entity }
- end
- end
+ @transaction.save!
+ @transaction.sync_account_later
+ redirect_to transactions_url, notice: t(".success")
end
def update
- respond_to do |format|
- sync_start_date = if transaction_params[:date]
- [ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
- else
- @transaction.date
- end
-
- if params[:transaction][:tag_id].present?
- tag = Current.family.tags.find(params[:transaction][:tag_id])
- @transaction.tags << tag unless @transaction.tags.include?(tag)
- end
+ @transaction.update! transaction_params
+ @transaction.sync_account_later
- if params[:transaction][:remove_tag_id].present?
- @transaction.tags.delete(params[:transaction][:remove_tag_id])
- end
-
- if @transaction.update(transaction_params)
- @transaction.account.sync_later(sync_start_date)
-
- format.html { redirect_to transaction_url(@transaction), notice: t(".success") }
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
- turbo_stream.replace("transaction_#{@transaction.id}", partial: "transactions/transaction", locals: { transaction: @transaction })
- ]
- end
- else
- format.html { render :edit, status: :unprocessable_entity }
- end
- end
+ redirect_to transaction_url(@transaction), notice: t(".success")
end
def destroy
- @account = @transaction.account
- sync_start_date = @account.transactions.where("date < ?", @transaction.date).order(date: :desc).first&.date
@transaction.destroy!
- @account.sync_later(sync_start_date)
-
- respond_to do |format|
- format.html { redirect_to transactions_url, notice: t(".success") }
- end
+ @transaction.sync_account_later
+ redirect_to transactions_url, notice: t(".success")
end
private
- def delete_search_param(params, key, value: nil)
- if value
- params[key]&.delete(value)
- params.delete(key) if params[key].empty? # Remove key if it's empty after deleting value
- else
- params.delete(key)
- end
-
- params
- end
-
- def ransack_session_key
- :ransack_transactions_q
- end
-
- # Use callbacks to share common setup or constraints between actions.
def set_transaction
- @transaction = Transaction.find(params[:id])
+ @transaction = Current.family.transactions.find(params[:id])
end
def amount
@@ -164,7 +70,11 @@ def nature
params[:transaction][:nature].to_s.inquiry
end
+ def search_params
+ params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
+ end
+
def transaction_params
- params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, :tag_id, :remove_tag_id).except(:tag_id, :remove_tag_id)
+ params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [], taggings_attributes: [ :id, :tag_id, :_destroy ])
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 288c3a78fb1..67082b18f71 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -20,23 +20,37 @@ def notification(text, **options, &block)
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
end
- # Wrap view with <%= modal do %> ... <% end %> to have it open in a modal
- # Make sure to add data-turbo-frame="modal" to the link/button that opens the modal
+ ##
+ # Helper to open a centered and overlayed modal with custom contents
+ #
+ # @example Basic usage
+ # <%= modal classes: "custom-class" do %>
+ #
Content here
+ # <% end %>
+ #
def modal(options = {}, &block)
content = capture &block
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
end
+ ##
+ # Helper to open a drawer on the right side of the screen with custom contents
+ #
+ # @example Basic usage
+ # <%= drawer do %>
+ #
Content here
+ # <% end %>
+ #
+ def drawer(&block)
+ content = capture &block
+ render partial: "shared/drawer", locals: { content: content }
+ end
+
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children, liabilities.children ].flatten
end
- def sidebar_modal(&block)
- content = capture &block
- render partial: "shared/sidebar_modal", locals: { content: content }
- end
-
def sidebar_link_to(name, path, options = {})
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
diff --git a/app/helpers/transactions/searches_helper.rb b/app/helpers/transactions/searches_helper.rb
new file mode 100644
index 00000000000..09bd882aafa
--- /dev/null
+++ b/app/helpers/transactions/searches_helper.rb
@@ -0,0 +1,37 @@
+module Transactions::SearchesHelper
+ def transaction_search_filters
+ [
+ { key: "account_filter", name: "Account", icon: "layers" },
+ { key: "date_filter", name: "Date", icon: "calendar" },
+ { key: "type_filter", name: "Type", icon: "shapes" },
+ { key: "amount_filter", name: "Amount", icon: "hash" },
+ { key: "category_filter", name: "Category", icon: "tag" },
+ { key: "merchant_filter", name: "Merchant", icon: "store" }
+ ]
+ end
+
+ def get_transaction_search_filter_partial_path(filter)
+ "transactions/searches/filters/#{filter[:key]}"
+ end
+
+ def get_default_transaction_search_filter
+ transaction_search_filters[0]
+ end
+
+ def transactions_path_without_param(param_key, param_value)
+ updated_params = request.query_parameters.deep_dup
+
+ q_params = updated_params[:q] || {}
+
+ current_value = q_params[param_key]
+ if current_value.is_a?(Array)
+ q_params[param_key] = current_value - [ param_value ]
+ else
+ q_params.delete(param_key)
+ end
+
+ updated_params[:q] = q_params
+
+ transactions_path(updated_params)
+ end
+end
diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb
index 7f270a1f8da..f5f4e9c4e22 100644
--- a/app/helpers/transactions_helper.rb
+++ b/app/helpers/transactions_helper.rb
@@ -1,24 +1,20 @@
module TransactionsHelper
- def transaction_filters
- [
- { name: "Account", partial: "account_filter", icon: "layers" },
- { name: "Date", partial: "date_filter", icon: "calendar" },
- { name: "Type", partial: "type_filter", icon: "shapes" },
- { name: "Amount", partial: "amount_filter", icon: "hash" },
- { name: "Category", partial: "category_filter", icon: "tag" },
- { name: "Merchant", partial: "merchant_filter", icon: "store" }
- ]
- end
+ def transactions_group(date, transactions, transaction_partial_path = "transactions/transaction")
+ header_left = content_tag :span do
+ "#{date.strftime('%b %d, %Y')} ยท #{transactions.size}".html_safe
+ end
- def transaction_filter_id(filter)
- "txn-#{filter[:name].downcase}-filter"
- end
+ header_right = content_tag :span do
+ format_money(-transactions.sum(&:amount_money))
+ end
- def transaction_filter_by_name(name)
- transaction_filters.find { |filter| filter[:name] == name }
- end
+ header = header_left.concat(header_right)
+
+ content = render partial: transaction_partial_path, collection: transactions
- def full_width_transaction_row?(route)
- route != "/"
+ render partial: "shared/list_group", locals: {
+ header: header,
+ content: content
+ }
end
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 2550acef542..c77f498f53e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -22,10 +22,6 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
- def self.ransackable_attributes(auth_object = nil)
- %w[name id]
- end
-
def balance_on(date)
balances.where("date <= ?", date).order(date: :desc).first&.balance
end
diff --git a/app/models/transaction.rb b/app/models/transaction.rb
index 75e4f63ce61..4ffdc137bca 100644
--- a/app/models/transaction.rb
+++ b/app/models/transaction.rb
@@ -1,20 +1,28 @@
class Transaction < ApplicationRecord
include Monetizable
+ monetize :amount
+
belongs_to :account
belongs_to :category, optional: true
belongs_to :merchant, optional: true
-
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
+ accepts_nested_attributes_for :taggings, allow_destroy: true
validates :name, :date, :amount, :account, presence: true
- monetize :amount
-
+ scope :ordered, -> { order(date: :desc) }
+ scope :active, -> { where(excluded: false) }
scope :inflows, -> { where("amount <= 0") }
scope :outflows, -> { where("amount > 0") }
- scope :active, -> { where(excluded: false) }
+ scope :by_name, ->(name) { where("transactions.name ILIKE ?", "%#{name}%") }
+ scope :with_categories, ->(categories) { joins(:category).where(transaction_categories: { name: categories }) }
+ scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) }
+ scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) }
+ scope :with_merchants, ->(merchants) { joins(:merchant).where(transaction_merchants: { name: merchants }) }
+ scope :on_or_after_date, ->(date) { where("transactions.date >= ?", date) }
+ scope :on_or_before_date, ->(date) { where("transactions.date <= ?", date) }
scope :with_converted_amount, ->(currency = Current.family.currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
@@ -26,92 +34,74 @@ class Transaction < ApplicationRecord
.where("er.rate IS NOT NULL OR transactions.currency = ?", currency)
}
- def self.daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
- # Sum spending and income for each day in the period with the given currency
- select(
- "gs.date",
- "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
- "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
- )
- .from(transactions.with_converted_amount(currency), :t)
- .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
- .group("gs.date")
+ def inflow?
+ amount <= 0
end
- def self.daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
- # Extend the period to include the rolling window
- period_with_rolling = period.extend_backward(period.date_range.count.days)
-
- # Aggregate the rolling sum of spending and income based on daily totals
- rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
- .select(
- "*",
- sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
- sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
- )
- .order("date")
-
- # Trim the results to the original period
- select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
+ def outflow?
+ amount > 0
end
- def self.ransackable_attributes(auth_object = nil)
- %w[name amount date]
- end
+ def sync_account_later
+ if destroyed?
+ sync_start_date = previous_transaction_date
+ else
+ sync_start_date = [ date_previously_was, date ].compact.min
+ end
- def self.ransackable_associations(auth_object = nil)
- %w[category merchant account]
+ account.sync_later(sync_start_date)
end
- def self.build_filter_list(params, family)
- filters = []
- valid_params = {}
+ class << self
+ def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
+ # Sum spending and income for each day in the period with the given currency
+ select(
+ "gs.date",
+ "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
+ "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
+ )
+ .from(transactions.with_converted_amount(currency), :t)
+ .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
+ .group("gs.date")
+ end
- date_filters = { gteq: nil, lteq: nil }
+ def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
+ # Extend the period to include the rolling window
+ period_with_rolling = period.extend_backward(period.date_range.count.days)
+
+ # Aggregate the rolling sum of spending and income based on daily totals
+ rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
+ .select(
+ "*",
+ sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
+ sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
+ )
+ .order("date")
+
+ # Trim the results to the original period
+ select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
+ end
- if params
- params.each do |key, value|
- next if value.blank?
+ def search(params)
+ query = all
+ query = query.by_name(params[:search]) if params[:search].present?
+ query = query.with_categories(params[:categories]) if params[:categories].present?
+ query = query.with_accounts(params[:accounts]) if params[:accounts].present?
+ query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present?
+ query = query.with_merchants(params[:merchants]) if params[:merchants].present?
+ query = query.on_or_after_date(params[:start_date]) if params[:start_date].present?
+ query = query.on_or_before_date(params[:end_date]) if params[:end_date].present?
+ query
+ end
+ end
- case key
- when "account_id_in"
- valid_accounts = value.select do |account_id|
- account = family.accounts.find_by(id: account_id)
- filters << { type: "account", value: account, original: { key: key, value: account_id } } if account.present?
- account.present?
- end
- valid_params[key] = valid_accounts unless valid_accounts.empty?
- when "category_id_in"
- valid_categories = value.select do |category_id|
- category = family.transaction_categories.find_by(id: category_id)
- filters << { type: "category", value: category, original: { key: key, value: category_id } } if category.present?
- category.present?
- end
- valid_params[key] = valid_categories unless valid_categories.empty?
- when "merchant_id_in"
- valid_merchants = value.select do |merchant_id|
- merchant = family.transaction_merchants.find_by(id: merchant_id)
- filters << { type: "merchant", value: merchant, original: { key: key, value: merchant_id } } if merchant.present?
- merchant.present?
- end
- valid_params[key] = valid_merchants unless valid_merchants.empty?
- when "category_name_or_merchant_name_or_account_name_or_name_cont"
- filters << { type: "search", value: value, original: { key: key, value: nil } }
- valid_params[key] = value
- when "date_gteq"
- date_filters[:gteq] = value
- valid_params[key] = value
- when "date_lteq"
- date_filters[:lteq] = value
- valid_params[key] = value
- end
- end
+ private
- unless date_filters.values.compact.empty?
- filters << { type: "date_range", value: date_filters, original: { key: "date_range", value: nil } }
- end
+ def previous_transaction_date
+ self.account
+ .transactions
+ .where("date < ?", date)
+ .order(date: :desc)
+ .first&.date
end
-
- [ filters, valid_params ]
- end
end
diff --git a/app/models/transaction/category.rb b/app/models/transaction/category.rb
index d884a3bdfb0..58955720f99 100644
--- a/app/models/transaction/category.rb
+++ b/app/models/transaction/category.rb
@@ -23,14 +23,6 @@ class Transaction::Category < ApplicationRecord
{ internal_category: "home_improvement", color: COLORS[7] }
]
- def self.ransackable_attributes(auth_object = nil)
- %w[name id]
- end
-
- def self.ransackable_associations(auth_object = nil)
- %w[]
- end
-
def self.create_default_categories(family)
if family.transaction_categories.size > 0
raise ArgumentError, "Family already has some categories"
diff --git a/app/models/transaction/merchant.rb b/app/models/transaction/merchant.rb
index e0395bb86e2..369ab916588 100644
--- a/app/models/transaction/merchant.rb
+++ b/app/models/transaction/merchant.rb
@@ -7,12 +7,4 @@ class Transaction::Merchant < ApplicationRecord
scope :alphabetically, -> { order(:name) }
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
-
- def self.ransackable_attributes(auth_object = nil)
- %w[name id]
- end
-
- def self.ransackable_associations(auth_object = nil)
- %w[]
- end
end
diff --git a/app/views/accounts/_transactions.html.erb b/app/views/accounts/_transactions.html.erb
index ab72aa206f1..c60eef767d7 100644
--- a/app/views/accounts/_transactions.html.erb
+++ b/app/views/accounts/_transactions.html.erb
@@ -7,11 +7,14 @@
New transaction
<% end %>
+
<% if transactions.empty? %>