1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Transactions cleanup (#817)

An overhaul and cleanup of the transactions feature including:

- Simplification of transactions search and filtering
- Consolidation of account sync logic after transaction change
- Split sidebar modal and modal into "drawer" and "modal" concepts
- Refactor of transaction partials and folder organization
- Cleanup turbo frames and streams for transaction updates, including new Transactions::RowsController for inline updates
- Refactored and added several integration and systems tests
This commit is contained in:
Zach Gollwitzer 2024-05-30 20:55:18 -04:00 committed by GitHub
parent ee162bbef7
commit 4ebc08e5a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 789 additions and 683 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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 @@ class TransactionsController < ApplicationController
.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
@transaction.update! transaction_params
@transaction.sync_account_later
if params[:transaction][:tag_id].present?
tag = Current.family.tags.find(params[:transaction][:tag_id])
@transaction.tags << tag unless @transaction.tags.include?(tag)
end
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 @@ class TransactionsController < ApplicationController
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

View file

@ -20,23 +20,37 @@ module ApplicationHelper
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 %>
# <div>Content here</div>
# <% 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 %>
# <div>Content here</div>
# <% 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 != "/")

View file

@ -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

View file

@ -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)
def full_width_transaction_row?(route)
route != "/"
content = render partial: transaction_partial_path, collection: transactions
render partial: "shared/list_group", locals: {
header: header,
content: content
}
end
end

View file

@ -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

View file

@ -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 self.ransackable_associations(auth_object = nil)
%w[category merchant account]
end
def self.build_filter_list(params, family)
filters = []
valid_params = {}
date_filters = { gteq: nil, lteq: nil }
if params
params.each do |key, value|
next if value.blank?
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
unless date_filters.values.compact.empty?
filters << { type: "date_range", value: date_filters, original: { key: "date_range", value: nil } }
end
def sync_account_later
if destroyed?
sync_start_date = previous_transaction_date
else
sync_start_date = [ date_previously_was, date ].compact.min
end
[ filters, valid_params ]
account.sync_later(sync_start_date)
end
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
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
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
private
def previous_transaction_date
self.account
.transactions
.where("date < ?", date)
.order(date: :desc)
.first&.date
end
end

View file

@ -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"

View file

@ -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

View file

@ -7,11 +7,14 @@
<span class="text-sm">New transaction</span>
<% end %>
</div>
<% if transactions.empty? %>
<p class="text-gray-500 py-4">No transactions for this account yet.</p>
<% else %>
<div class="space-y-6">
<%= render partial: "transactions/transaction_group", collection: transactions.group_by(&:date), as: :transaction_group %>
<% transactions.group_by(&:date).each do |date, transactions| %>
<%= transactions_group(date, transactions) %>
<% end %>
</div>
<% end %>
</div>

View file

@ -9,7 +9,9 @@
</div>
<div class="mb-8 space-y-4">
<%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %>
<% @import.dry_run.group_by(&:date).each do |date, draft_transactions| %>
<%= transactions_group(date, draft_transactions, "imports/transactions/transaction") %>
<% end %>
</div>
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>

View file

@ -1,18 +1,20 @@
<%# locals: (transaction:) %>
<div class="text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4">
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
<div class="text-gray-900 grid grid-cols-8 items-center py-4 text-sm font-medium px-4">
<div class="col-span-3">
<%= render "transactions/name", transaction: transaction %>
</div>
<div class="w-48">
<div class="col-span-2">
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
</div>
<div class="w-48 flex gap-1">
<div class="col-span-2 flex items-center gap-1">
<% transaction.tags.each do |tag| %>
<%= render partial: "tags/badge", locals: { tag: tag } %>
<% end %>
</div>
<div class="ml-auto">
<%= content_tag :p, format_money(Money.new(-transaction.amount, @import.account.currency)), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
<div class="col-span-1 justify-self-end">
<%= render "transactions/amount", transaction: transaction %>
</div>
</div>

View file

@ -1,13 +0,0 @@
<%# locals: (transaction_group:) %>
<% date = transaction_group[0] %>
<% transactions = transaction_group[1] %>
<div class="bg-gray-25 rounded-xl p-1 w-full">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<h4><%= date.strftime("%b %d, %Y") %> &middot; <%= transactions.size %></h4>
<span><%= format_money Money.new(-transactions.sum { |t| t.amount }, @import.account.currency) %></span>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render partial: "imports/transactions/transaction", collection: transactions %>
</div>
</div>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html class="h-full">
<html class="h-full" lang="en">
<head>
<title><%= content_for(:title) || "Maybe" %></title>
@ -13,7 +13,8 @@
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Maybe">
@ -30,10 +31,13 @@
<%= content_for?(:content) ? yield(:content) : yield %>
<%= turbo_frame_tag "modal" %>
<%= turbo_frame_tag "drawer" %>
<%= render "shared/confirm_modal" %>
<% if self_hosted? %>
<%= render "shared/app_version" %>
<% end %>
</body>
</html>

View file

@ -161,9 +161,12 @@
<p><%= t(".no_transactions") %></p>
</div>
<% else %>
<div class="text-gray-500 flex items-center justify-center flex-col bg-gray-25 rounded-md">
<%= render partial: "transactions/transaction_group", collection: @transactions.group_by(&:date), as: :transaction_group %>
<p class="py-2 text-sm"><%= link_to t(".view_all"), transactions_path %></p>
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
<% @transactions.group_by(&:date).each do |date, transactions| %>
<%= transactions_group(date, transactions, "pages/dashboard/transactions/transaction") %>
<% end %>
<p class="py-2 text-sm text-center"><%= link_to t(".view_all"), transactions_path %></p>
</div>
<% end %>
</div>

View file

@ -0,0 +1,9 @@
<div class="text-gray-900 flex items-center py-4 text-sm font-medium px-4">
<div class="grow">
<%= render "transactions/name", transaction: transaction %>
</div>
<div class="ml-auto">
<%= render "transactions/amount", transaction: transaction %>
</div>
</div>

View file

@ -1,4 +1,4 @@
<%= turbo_frame_tag "modal" do %>
<%= turbo_frame_tag "drawer" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col h-full p-4">
<div class="flex justify-end items-center h-9">

View file

@ -0,0 +1,9 @@
<%# locals: (header:, content:) %>
<div class="bg-gray-25 rounded-xl p-1 w-full">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<%= header %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= content %>
</div>
</div>

View file

@ -0,0 +1,3 @@
<%= content_tag :p,
format_money(-transaction.amount_money),
class: ["text-green-600": transaction.inflow?] %>

View file

@ -0,0 +1,4 @@
<div class="flex flex-col items-center justify-center py-40">
<p class="text-gray-500 mb-2"><%= t(".title") %></p>
<p class="text-gray-400 max-w-xs text-center"><%= t(".description") %></p>
</div>

View file

@ -1,48 +0,0 @@
<%# locals: (filter:) %>
<div class="flex items-center gap-1 text-sm border border-alpha-black-200 rounded-3xl p-1.5">
<% case filter[:type] %>
<% when "account" %>
<div class="flex items-center gap-2">
<div class="w-5 h-5 bg-blue-600/10 text-xs flex items-center justify-center rounded-full"><%= filter[:value].name[0].upcase %></div>
<p><%= filter[:value].name %></p>
</div>
<% when "category" %>
<div class="flex items-center gap-2">
<div class="w-2 h-4 text-xs flex items-center justify-center rounded-full" style="background-color: <%= filter[:value].color %>"></div>
<p><%= filter[:value].name %></p>
</div>
<% when "merchant" %>
<div class="flex items-center gap-2">
<div class="w-2 h-4 text-xs flex items-center justify-center rounded-full" style="background-color: <%= filter[:value].color %>"></div>
<p><%= filter[:value].name %></p>
</div>
<% when "search" %>
<div class="flex items-center gap-2">
<%= lucide_icon "text", class: "w-5 h-5 text-gray-500" %>
<p><%= "\"#{filter[:value]}\"".truncate(20) %></p>
</div>
<% when "date_range" %>
<div class="flex items-center gap-2">
<%= lucide_icon "calendar", class: "w-5 h-5 text-gray-500" %>
<p>
<% if filter[:value][:gteq] && filter[:value][:lteq] %>
<%= filter[:value][:gteq] %> &rarr; <%= filter[:value][:lteq] %>
<% elsif filter[:value][:gteq] %>
on or after <%= filter[:value][:gteq] %>
<% elsif filter[:value][:lteq] %>
on or before <%= filter[:value][:lteq] %>
<% end %>
</p>
</div>
<% end %>
<%= form_with url: search_transactions_path, html: { class: "flex items-center" } do |form| %>
<%= form.hidden_field :remove_param, value: filter[:original][:key] %>
<% if filter[:original][:value] %>
<%= form.hidden_field :remove_param_value, value: filter[:original][:value] %>
<% else %>
<% end %>
<%= form.button type: "submit", class: "hover:text-gray-900" do %>
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
<% end %>
<% end %>
</div>

View file

@ -1,10 +0,0 @@
<%# locals: (filters:) %>
<div>
<%= turbo_frame_tag "transactions_filters" do %>
<div class="flex items-center flex-wrap gap-2">
<% filters.each do |filter| %>
<%= render partial: "transactions/filter", locals: { filter: filter } %>
<% end %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,33 @@
<header class="flex justify-between items-center text-gray-900 font-medium">
<h1 class="text-xl">Transactions</h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to transaction_categories_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_categories") %></span>
<% end %>
<%= link_to imports_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_imports") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium">New transaction</p>
<% end %>
</div>
</div>
</header>

View file

@ -1,32 +0,0 @@
<%# locals: (transactions:, pagy:) %>
<div>
<%= turbo_frame_tag "transactions_list" do %>
<% if transactions.empty? %>
<div class="flex flex-col items-center justify-center py-40">
<p class="text-gray-500 mb-2">No transactions found</p>
<p class="text-gray-400 max-w-xs text-center">Try adding a transaction, editing filters or refining your search</p>
</div>
<% else %>
<div class="bg-gray-25 rounded-xl px-5 py-3 text-xs font-medium text-gray-500 flex items-center gap-6 mb-4">
<div class="w-96">
<p class="uppercase">transaction</p>
</div>
<div class="w-48">
<p class="uppercase">category</p>
</div>
<div class="grow uppercase flex justify-between items-center gap-5 text-xs font-medium text-gray-500">
<p>account</p>
<p>amount</p>
</div>
</div>
<div class="space-y-6">
<%= render partial: "transactions/transaction_group", collection: transactions.group_by(&:date), as: :transaction_group %>
</div>
<% end %>
<% if pagy.pages > 1 %>
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
<%= render partial: "transactions/pagination", locals: { pagy: pagy } %>
</nav>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,16 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= transaction.name[0].upcase %>
</div>
<div class="text-gray-900 truncate">
<% if transaction.new_record? %>
<%= content_tag :p, transaction.name %>
<% else %>
<%= link_to transaction.name,
transaction_path(transaction),
data: { turbo_frame: "drawer" },
class: "hover:underline hover:text-gray-800" %>
<% end %>
</div>
<% end %>

View file

@ -1,24 +1,7 @@
<!-- start mobile pagination -->
<div class="flex flex-1 justify-center md:hidden">
<% if pagy.prev %>
<%= link_to "Previous", pagy_url_for(pagy, pagy.prev), class: "relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50" %>
<% else %>
<div class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-200">Previous</div>
<% end %>
<% if pagy.next %>
<%= link_to "Next", pagy_url_for(pagy, pagy.next), class: "relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50" %>
<% else %>
<div class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-200">Next</div>
<% end %>
</div>
<!-- end mobile pagination -->
<!-- start desktop pagination -->
<div class="hidden md:-mt-px md:flex">
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
<div>
<% if pagy.prev %>
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %>
<% end %>
<% else %>
@ -30,7 +13,7 @@
<div class="bg-gray-25 rounded-xl">
<% pagy.series.each do |series_item| %>
<% if series_item.is_a?(Integer) %>
<%= link_to pagy_url_for(pagy, series_item), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to pagy_url_for(pagy, series_item), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= series_item %>
<% end %>
<% elsif series_item.is_a?(String) %>
@ -42,16 +25,15 @@
<% end %>
<% end %>
</div>
<div>
<% if pagy.next %>
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
<% end %>
<% else %>
<div class="inline-flex items-center px-3 py-3 text-sm font-medium hover:border-gray-300">
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-200") %>
</div>
<div>
<% if pagy.next %>
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
<% end %>
</div>
</div>
<!-- end desktop pagination -->
<% else %>
<div class="inline-flex items-center px-3 py-3 text-sm font-medium hover:border-gray-300">
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-200") %>
</div>
<% end %>
</div>
</nav>

View file

@ -1,55 +0,0 @@
<%# locals: (q:) %>
<div>
<%= turbo_frame_tag "transactions_search_form" do %>
<%= search_form_for @q, url: search_transactions_path, html: { method: :post, data: { turbo_frame: "transactions_list" } } do |form| %>
<div class="flex gap-2 mb-4">
<div class="grow">
<%= render partial: "transactions/search_form/search_filter", locals: { form: form } %>
</div>
<div data-controller="menu" class="relative">
<button data-menu-target="button" type="button" class="border border-gray-200 block h-full rounded-lg flex items-center gap-2 px-4">
<%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
<p class="text-sm font-medium text-gray-900">Filter</p>
</button>
<div
data-menu-target="content"
data-controller="tabs"
data-tabs-active-class="bg-gray-25 text-gray-900"
data-tabs-default-tab-value="<%= transaction_filter_id(transaction_filter_by_name("Account")) %>"
class="hidden absolute flex z-10 h-80 w-[540px] top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
<div class="flex w-44 flex-col items-start p-3 text-sm font-medium text-gray-500 border-r border-r-alpha-black-25">
<% transaction_filters.each do |filter| %>
<button
class="flex text-gray-500 hover:bg-gray-25 items-center gap-2 px-3 rounded-md py-2 w-full"
type="button"
data-id="<%= transaction_filter_id(filter) %>"
data-tabs-target="btn"
data-action="tabs#select">
<%= lucide_icon(filter[:icon], class: "w-5 h-5") %>
<span class="text-sm font-medium"><%= filter[:name] %></span>
</button>
<% end %>
</div>
<div class="flex flex-col grow">
<div class="grow p-2 border-b border-b-alpha-black-25 overflow-y-auto">
<% transaction_filters.each do |filter| %>
<div id="<%= transaction_filter_id(filter) %>" data-tabs-target="tab">
<%= render partial: "transactions/search_form/#{filter[:partial]}", locals: { form: form } %>
</div>
<% end %>
</div>
<div class="flex justify-end items-center gap-2 bg-white p-3">
<%= button_tag type: "reset", data: { action: "menu#close" }, class: "py-2 px-3 bg-gray-50 rounded-lg text-sm text-gray-900 font-medium" do %>
Cancel
<% end %>
<%= button_tag type: "submit", class: "py-2 px-3 bg-gray-900 rounded-lg text-sm text-white font-medium" do %>
Apply
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
<% end %>
</div>

View file

@ -1,21 +1,19 @@
<%# locals: (totals:) %>
<%= turbo_frame_tag "transactions_summary" do %>
<div class="grid grid-cols-3 bg-white rounded-xl border border-alpha-black-25 shadow-xs px-4 divide-x divide-alpha-black-100">
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Total transactions</p>
<p class="text-gray-900 font-medium text-xl"><%= totals[:count] %></p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Income</p>
<p class="text-gray-900 font-medium text-xl">
<%= format_money totals[:income] %>
</p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Expenses</p>
<p class="text-gray-900 font-medium text-xl">
<%= format_money totals[:expense] %>
</p>
</div>
<%# locals: (totals:) %>
<div class="grid grid-cols-3 bg-white rounded-xl border border-alpha-black-25 shadow-xs px-4 divide-x divide-alpha-black-100">
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Total transactions</p>
<p class="text-gray-900 font-medium text-xl" id="total-transactions"><%= totals[:count] %></p>
</div>
<% end %>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Income</p>
<p class="text-gray-900 font-medium text-xl" id="total-income">
<%= format_money totals[:income] %>
</p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Expenses</p>
<p class="text-gray-900 font-medium text-xl" id="total-expense">
<%= format_money totals[:expense] %>
</p>
</div>
</div>

View file

@ -1,22 +1,17 @@
<%# locals: (transaction:) %>
<%= turbo_frame_tag dom_id(transaction), class:"text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4" do %>
<% if full_width_transaction_row?(request.path) %>
<%= link_to transaction_path(transaction), data: { turbo_frame: "modal" }, class: "group" do %>
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
<% end %>
<div class="w-48">
<%= render partial: "transactions/categories/menu", locals: { transaction: } %>
</div>
<div>
<p><%= transaction.account.name %></p>
</div>
<% else %>
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
<div class="w-36">
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
</div>
<% end %>
<div class="ml-auto">
<%= content_tag :p, format_money(-transaction.amount_money), class: ["whitespace-nowrap", { "text-green-600": transaction.amount.negative? }] %>
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %>
<div class="col-span-4">
<%= render "transactions/name", transaction: transaction %>
</div>
<div class="col-span-3">
<%= render "transactions/categories/menu", transaction: transaction %>
</div>
<%= link_to transaction.account.name,
account_path(transaction.account),
class: ["col-span-3 hover:underline"] %>
<div class="col-span-2 ml-auto">
<%= render "transactions/amount", transaction: transaction %>
</div>
<% end %>

View file

@ -1,13 +0,0 @@
<%# locals: (transaction_group:) %>
<% date = transaction_group[0] %>
<% transactions = transaction_group[1] %>
<div class="bg-gray-25 rounded-xl p-1 w-full">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<h4><%= date.strftime("%b %d, %Y") %> &middot; <%= transactions.size %></h4>
<span><%= format_money -transactions.sum(&:amount_money) %></span>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render partial: "transactions/transaction", collection: transactions %>
</div>
</div>

View file

@ -1,10 +0,0 @@
<%# locals: (name:) %>
<%= content_tag :div, class: ["flex items-center gap-2", { "w-40": !full_width_transaction_row?(request.path), "w-96": full_width_transaction_row?(request.path) }] do %>
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= name[0].upcase %>
</div>
<p class="text-gray-900 group-hover:underline group-hover:text-gray-800 truncate">
<%= name %>
</p>
<% end %>

View file

@ -2,7 +2,7 @@
<% is_selected = category.id === @selected_category&.id %>
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
<%= button_to transaction_path(@transaction, transaction: { category_id: category.id }), method: :patch, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<%= button_to transaction_row_path(@transaction, transaction: { category_id: category.id }), method: :patch, data: { turbo_frame: dom_id(@transaction) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>

View file

@ -25,8 +25,9 @@
<% end %>
<% if @transaction.category %>
<%= button_to transaction_path(@transaction),
<%= button_to transaction_row_path(@transaction),
method: :patch,
data: { turbo_frame: dom_id(@transaction) },
params: { transaction: { category_id: nil } },
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
<%= lucide_icon "minus", class: "w-5 h-5" %>

View file

@ -1,8 +0,0 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl">Editing transaction</h1>
<%= render "form", transaction: @transaction %>
<%= link_to "Show this transaction", @transaction, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to transactions", transactions_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View file

@ -1,43 +1,32 @@
<div class="space-y-4">
<div class="flex justify-between items-center text-gray-900 font-medium">
<h1 class="text-xl">Transactions</h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to transaction_categories_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_categories") %></span>
<% end %>
<%= link_to imports_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_imports") %></span>
<% end %>
</div>
<%= render "header" %>
<% end %>
<%= render partial: "transactions/summary", locals: { totals: @totals } %>
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
<% end %>
<div id="transactions" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium">New transaction</p>
<%= render partial: "transactions/searches/search", locals: { transactions: @transactions } %>
<% if @transactions.present? %>
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
<p class="col-span-4">transaction</p>
<p class="col-span-3 pl-4">category</p>
<p class="col-span-3">account</p>
<p class="col-span-2 justify-self-end">amount</p>
</div>
<div class="space-y-6">
<% @transactions.group_by(&:date).each do |date, transactions| %>
<%= transactions_group(date, transactions) %>
<% end %>
</div>
</div>
</div>
<div>
<%= render partial: "transactions/summary", locals: { totals: @totals } %>
</div>
<div id="transactions" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
<%= render partial: "transactions/search_form", locals: { q: @q } %>
<%= render partial: "transactions/filters", locals: { filters: @filter_list } %>
<%= render partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy } %>
<% else %>
<%= render "empty" %>
<% end %>
<% if @pagy.pages > 1 %>
<%= render "pagination", pagy: @pagy %>
<% end %>
</div>
</div>

View file

@ -0,0 +1 @@
<%= render "transactions/transaction", transaction: @transaction %>

View file

@ -1,5 +0,0 @@
<%# locals: (form:) %>
<div class="p-3">
<%= form.date_field :date_gteq, placeholder: "Start date", class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm" %>
<%= form.date_field :date_lteq, placeholder: "End date", class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm mt-2" %>
</div>

View file

@ -1,8 +0,0 @@
<%# locals: (form:) %>
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
<%= form.search_field :category_name_or_merchant_name_or_account_name_or_name_cont,
placeholder: "Search transaction by name, merchant, category or amount",
class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg",
"data-auto-submit-form-target": "auto" %>
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
</div>

View file

@ -0,0 +1,27 @@
<%# locals: (transactions:) %>
<%= form_with url: transactions_path,
id: "transactions-search",
scope: :q,
method: :get,
data: { controller: "auto-submit-form" } do |form| %>
<div class="flex gap-2 mb-4">
<div class="grow">
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
<%= form.text_field :search,
placeholder: "Search transactions by name",
value: @q[:search],
class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg",
"data-auto-submit-form-target": "auto" %>
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
</div>
</div>
<div data-controller="menu" class="relative">
<button id="transaction-filters-button" data-menu-target="button" type="button" class="border border-gray-200 block h-full rounded-lg flex items-center gap-2 px-4">
<%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
<p class="text-sm font-medium text-gray-900">Filter</p>
</button>
<%= render "transactions/searches/menu", form: form %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,37 @@
<div
id="transaction-filters-menu"
data-menu-target="content"
data-controller="tabs"
data-tabs-active-class="bg-gray-25 text-gray-900"
data-tabs-default-tab-value="<%= get_default_transaction_search_filter[:key] %>"
class="hidden absolute flex z-10 h-80 w-[540px] top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
<div class="flex w-44 flex-col items-start p-3 text-sm font-medium text-gray-500 border-r border-r-alpha-black-25">
<% transaction_search_filters.each do |filter| %>
<button
class="flex text-gray-500 hover:bg-gray-25 items-center gap-2 px-3 rounded-md py-2 w-full"
type="button"
data-id="<%= filter[:key] %>"
data-tabs-target="btn"
data-action="tabs#select">
<%= lucide_icon(filter[:icon], class: "w-5 h-5") %>
<span class="text-sm font-medium"><%= filter[:name] %></span>
</button>
<% end %>
</div>
<div class="flex flex-col grow">
<div class="grow p-2 border-b border-b-alpha-black-25 overflow-y-auto">
<% transaction_search_filters.each do |filter| %>
<div id="<%= filter[:key] %>" data-tabs-target="tab">
<%= render partial: get_transaction_search_filter_partial_path(filter), locals: { form: form } %>
</div>
<% end %>
</div>
<div class="flex justify-end items-center gap-2 bg-white p-3">
<%= button_tag type: "reset", data: { action: "menu#close" }, class: "py-2 px-3 bg-gray-50 rounded-lg text-sm text-gray-900 font-medium" do %>
Cancel
<% end %>
<%= form.submit "Apply", name: nil, class: "py-2 px-3 bg-gray-900 rounded-lg text-sm text-white font-medium" %>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<%= render partial: "transactions/searches/form", locals: { transactions: transactions } %>
<ul id="transaction-search-filters" class="flex items-center flex-wrap gap-2">
<% @q.each do |param_key, param_value| %>
<% unless param_value.blank? %>
<% Array(param_value).each do |value| %>
<%= render partial: "transactions/searches/filters/badge", locals: { param_key: param_key, param_value: value } %>
<% end %>
<% end %>
<% end %>
</ul>

View file

@ -7,8 +7,15 @@
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.family.accounts.alphabetically.each do |account| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= account.name %>">
<%= form.check_box :account_id_in, { multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, account.id, nil %>
<%= form.label :account_id_in, account.name, value: account.id, class: "text-sm text-gray-900" %>
<%= form.check_box :accounts,
{
multiple: true,
checked: @q[:accounts]&.include?(account.name),
class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
},
account.name,
nil %>
<%= form.label :accounts, account.name, value: account.name, class: "text-sm text-gray-900" %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,34 @@
<%# locals: (param_key:, param_value:) %>
<li class="flex items-center gap-1 text-sm border border-alpha-black-200 rounded-3xl p-1.5">
<% if param_key == "start_date" || param_key == "end_date" %>
<div class="flex items-center gap-2">
<%= lucide_icon "calendar", class: "w-5 h-5 text-gray-500" %>
<p>
<% if param_key == "start_date" %>
on or after <%= param_value %>
<% else %>
on or before <%= param_value %>
<% end %>
</p>
</div>
<% elsif param_key == "search" %>
<div class="flex items-center gap-2">
<%= lucide_icon "text", class: "w-5 h-5 text-gray-500" %>
<p><%= "\"#{param_value}\"".truncate(20) %></p>
</div>
<% elsif param_key == "accounts" %>
<div class="flex items-center gap-2">
<div class="w-5 h-5 bg-blue-600/10 text-xs flex items-center justify-center rounded-full"><%= param_value[0].upcase %></div>
<p><%= param_value %></p>
</div>
<% else %>
<div class="flex items-center gap-2">
<p><%= param_value %></p>
</div>
<% end %>
<%= link_to transactions_path_without_param(param_key, param_value), data: { id: "clear-param-btn", turbo: false }, class: "flex items-center" do %>
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
<% end %>
</li>

View file

@ -7,8 +7,15 @@
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.family.transaction_categories.alphabetically.each do |transaction_category| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= transaction_category.name %>">
<%= form.check_box :category_id_in, { "data-auto-submit-form-target": "auto", multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, transaction_category.id, nil %>
<%= form.label :category_id_in, transaction_category.name, value: transaction_category.id, class: "text-sm text-gray-900 cursor-pointer" do %>
<%= form.check_box :categories,
{
multiple: true,
checked: @q[:categories]&.include?(transaction_category.name),
class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
},
transaction_category.name,
nil %>
<%= form.label :categories, transaction_category.name, value: transaction_category.name, class: "text-sm text-gray-900 cursor-pointer" do %>
<%= render partial: "transactions/categories/badge", locals: { category: transaction_category } %>
<% end %>
</div>

View file

@ -0,0 +1,11 @@
<%# locals: (form:) %>
<div class="p-3">
<%= form.date_field :start_date,
placeholder: "Start date",
value: @q[:start_date],
class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm" %>
<%= form.date_field :end_date,
placeholder: "End date",
value: @q[:end_date],
class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm mt-2" %>
</div>

View file

@ -7,8 +7,15 @@
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.family.transaction_merchants.alphabetically.each do |merchant| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
<%= form.check_box :merchant_id_in, { multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, merchant.id, nil %>
<%= form.label :merchant_id_in, merchant.name, value: merchant.id, class: "text-sm text-gray-900" %>
<%= form.check_box :merchants,
{
multiple: true,
checked: @q[:merchants]&.include?(merchant.name),
class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
},
merchant.name,
nil %>
<%= form.label :merchants, merchant.name, value: merchant.name, class: "text-sm text-gray-900" %>
</div>
<% end %>
</div>

View file

@ -1,4 +1,4 @@
<%= sidebar_modal do %>
<%= drawer do %>
<h3 class="font-medium mb-1">
<span class="text-2xl"><%= format_money @transaction.amount_money %></span>
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
@ -62,22 +62,15 @@
</summary>
<div class="mb-2">
<% if @transaction.tags.any? %>
<div class="pt-3 pb-2 flex flex-wrap items-center gap-1">
<% @transaction.tags.each do |tag| %>
<div class="relative">
<%= render partial: "tags/badge", locals: { tag: tag } %>
<%= button_to transaction_path(@transaction, transaction: { remove_tag_id: tag.id }), method: :patch, "data-turbo": false, class: "absolute -top-2 -right-1 px-0.5 py rounded-full hover:bg-alpha-black-200 border border-alpha-black-100" do %>
<%= lucide_icon("x", class: "w-3 h-3") %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form", turbo: false } } do |f| %>
<%= f.collection_select :tag_id, Current.family.tags.alphabetically.excluding(@transaction.tags), :id, :name, { prompt: "Select a tag", label: "Select a tag", class: "placeholder:text-gray-500" }, "data-auto-submit-form-target": "auto", "data-turbo": false %>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.select :tag_ids,
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), @transaction.tag_ids),
{
multiple: true,
label: t(".select_tags"),
class: "placeholder:text-gray-500"
},
"data-auto-submit-form-target": "auto" %>
<% end %>
</div>

View file

@ -0,0 +1,3 @@
require "pagy/extras/overflow"
Pagy::DEFAULT[:overflow] = :last_page

View file

@ -46,6 +46,9 @@ en:
success: New transaction created successfully
destroy:
success: Transaction deleted successfully
empty:
description: Try adding a transaction, editing filters or refining your search
title: No transactions found
form:
account: Account
account_prompt: Select an Account
@ -59,7 +62,7 @@ en:
income: Income
submit: Add transaction
transfer: Transfer
index:
header:
edit_categories: Edit categories
edit_imports: Edit imports
import: Import
@ -90,5 +93,7 @@ en:
title: New merchant
update:
success: Merchant updated successfully
show:
select_tags: Select one or more tags
update:
success: Transaction updated successfully

View file

@ -46,6 +46,8 @@ Rails.application.routes.draw do
match "search" => "transactions#search", via: %i[ get post ]
scope module: :transactions, as: :transaction do
resources :rows, only: %i[ show update ]
resources :categories do
resources :deletions, only: %i[ new create ], module: :categories
collection do

View file

@ -6,6 +6,8 @@ namespace :demo_data do
family.accounts.delete_all
ExchangeRate.delete_all
family.transaction_categories.delete_all
Tagging.delete_all
family.tags.delete_all
Transaction::Category.create_default_categories(family)
user = User.find_or_create_by(email: "user@maybe.local") do |u|
@ -17,6 +19,15 @@ namespace :demo_data do
puts "Reset user: #{user.email} with family: #{family.name}"
# Tags
tags = [
{ name: "Hawaii Trip", color: "#e99537" },
{ name: "Trips", color: "#4da568" },
{ name: "Emergency Fund", color: "#db5a54" }
]
family.tags.insert_all(tags)
# Mock exchange rates for last 60 days (these rates are reasonable for EUR:USD, but not exact)
exchange_rates = (0..60).map do |days_ago|
{
@ -69,16 +80,16 @@ namespace :demo_data do
# ========== Transactions ================
multi_currency_checking_transactions = [
{ date: Date.today - 45, amount: 3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 41, amount: -1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 39, amount: -200, name: "Groceries", currency: "EUR" },
{ date: Date.today - 34, amount: 3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 31, amount: -1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 28, amount: -100, name: "Utilities", currency: "EUR" },
{ date: Date.today - 28, amount: 3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 28, amount: -1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 28, amount: -50, name: "Internet Bill", currency: "EUR" },
{ date: Date.today - 14, amount: 3000, name: "Paycheck", currency: "USD" }
{ date: Date.today - 45, amount: -3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 41, amount: 1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 39, amount: 200, name: "Groceries", currency: "EUR" },
{ date: Date.today - 34, amount: -3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 31, amount: 1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 28, amount: 100, name: "Utilities", currency: "EUR" },
{ date: Date.today - 28, amount: -3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 28, amount: 1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 28, amount: 50, name: "Internet Bill", currency: "EUR" },
{ date: Date.today - 14, amount: -3000, name: "Paycheck", currency: "USD" }
]
checking_transactions = [
@ -171,24 +182,24 @@ namespace :demo_data do
]
mortgage_transactions = [
{ date: Date.today - 90, amount: -1500, name: "Mortgage Payment" },
{ date: Date.today - 60, amount: -1500, name: "Mortgage Payment" },
{ date: Date.today - 30, amount: -1500, name: "Mortgage Payment" }
{ date: Date.today - 90, amount: 1500, name: "Mortgage Payment" },
{ date: Date.today - 60, amount: 1500, name: "Mortgage Payment" },
{ date: Date.today - 30, amount: 1500, name: "Mortgage Payment" }
]
car_loan_transactions = [
{ date: 12.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 11.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 10.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 9.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 8.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 7.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 6.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 5.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 4.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 3.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 2.months.ago.to_date, amount: -1250, name: "Car Loan Payment" },
{ date: 1.month.ago.to_date, amount: -1250, name: "Car Loan Payment" }
{ date: 12.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 11.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 10.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 9.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 8.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 7.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 6.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 5.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 4.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 3.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 2.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 1.month.ago.to_date, amount: 1250, name: "Car Loan Payment" }
]
# ========== Valuations ================
@ -261,6 +272,21 @@ namespace :demo_data do
mortgage.transactions.insert_all(mortgage_transactions)
car_loan.transactions.insert_all(car_loan_transactions)
# Tag a few transactions
emergency_fund_tag = Tag.find_by(name: "Emergency Fund")
trips_tag = Tag.find_by(name: "Trips")
hawaii_trip_tag = Tag.find_by(name: "Hawaii Trip")
savings.transactions.order(date: :desc).limit(5).each do |txn|
txn.tags << emergency_fund_tag
txn.save!
end
checking.transactions.order(date: :desc).limit(5).each do |txn|
txn.tags = [ trips_tag, hawaii_trip_tag ]
txn.save!
end
puts "Created demo accounts, transactions, and valuations for family: #{family.name}"
puts "Syncing accounts... This may take a few seconds."

View file

@ -1,6 +1,10 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
setup do
Capybara.default_max_wait_time = 5
end
driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]
private

View file

@ -4,12 +4,49 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@transaction = transactions(:checking_one)
@account = @transaction.account
@recent_transactions = @user.family.transactions.ordered.limit(20).to_a
end
test "should get index" do
test "should get paginated index with most recent transactions first" do
get transactions_url
assert_response :success
@recent_transactions.first(10).each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
test "transaction count represents filtered total" do
get transactions_url
assert_dom "#total-transactions", count: 1, text: @user.family.transactions.count.to_s
new_transaction = @user.family.accounts.first.transactions.create! \
name: "Transaction to search for",
date: Date.current,
amount: 0
get transactions_url(q: { search: new_transaction.name })
# Only finds 1 transaction that matches filter
assert_dom "#" + dom_id(new_transaction), count: 1
assert_dom "#total-transactions", count: 1, text: "1"
end
test "can navigate to paginated result" do
get transactions_url(page: 2)
assert_response :success
@recent_transactions[10, 10].each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
test "loads last page when page is out of range" do
user_oldest_transaction = @user.family.transactions.ordered.last
get transactions_url(page: 9999999999)
assert_response :success
assert_dom "#" + dom_id(user_oldest_transaction), count: 1
end
test "should get new" do
@ -24,35 +61,25 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "should create transaction" do
name = "transaction_name"
account = @user.family.accounts.first
transaction_params = {
account_id: account.id,
amount: 100.45,
currency: "USD",
date: Date.current,
name: "Test transaction"
}
assert_difference("Transaction.count") do
post transactions_url, params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: } }
post transactions_url, params: { transaction: transaction_params }
end
assert_equal transaction_params[:amount].to_d, Transaction.order(created_at: :desc).first.amount
assert_equal flash[:notice], "New transaction created successfully"
assert_enqueued_with(job: AccountSyncJob)
assert_redirected_to transactions_url
end
test "create should sync account with correct start date" do
assert_enqueued_with(job: AccountSyncJob, args: [ @account, @transaction.date ]) do
post transactions_url, params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: @transaction.name } }
end
end
test "creation preserves decimals" do
assert_difference("Transaction.count") do
post transactions_url, params: { transaction: {
nature: "expense",
account_id: @transaction.account_id,
amount: 123.45,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name } }
end
assert_redirected_to transactions_url
assert_equal 123.45.to_d, Transaction.order(created_at: :desc).first.amount
end
test "expenses are positive" do
assert_difference("Transaction.count") do
post transactions_url, params: { transaction: {
@ -88,26 +115,19 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "should get edit" do
get edit_transaction_url(@transaction)
assert_response :success
end
test "should update transaction" do
patch transaction_url(@transaction), params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: @transaction.name } }
patch transaction_url(@transaction), params: {
transaction: {
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name
}
}
assert_redirected_to transaction_url(@transaction)
end
test "update should sync account with correct start date" do
new_date = @transaction.date - 1.day
assert_enqueued_with(job: AccountSyncJob, args: [ @account, new_date ]) do
patch transaction_url(@transaction), params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: new_date, name: @transaction.name } }
end
new_date = @transaction.reload.date + 1.day
assert_enqueued_with(job: AccountSyncJob, args: [ @account, @transaction.date ]) do
patch transaction_url(@transaction), params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: new_date, name: @transaction.name } }
end
assert_enqueued_with(job: AccountSyncJob)
end
test "should destroy transaction" do
@ -116,17 +136,6 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
assert_redirected_to transactions_url
end
test "destroy should sync account with correct start date" do
first, second = @transaction.account.transactions.order(:date).all
assert_enqueued_with(job: AccountSyncJob, args: [ @account, first.date ]) do
delete transaction_url(second)
end
assert_enqueued_with(job: AccountSyncJob, args: [ @account, nil ]) do
delete transaction_url(first)
end
assert_enqueued_with(job: AccountSyncJob)
end
end

View file

@ -1,6 +1,18 @@
require "test_helper"
class Account::SyncableTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
@account = accounts(:savings_with_valuation_overrides)
end
test "triggers sync job" do
assert_enqueued_with(job: AccountSyncJob, args: [ @account, Date.current ]) do
@account.sync_later(Date.current)
end
end
test "account has no balances until synced" do
account = accounts(:savings_with_valuation_overrides)

View file

@ -1,14 +1,44 @@
require "test_helper"
class TransactionTest < ActiveSupport::TestCase
setup do
@transaction = transactions(:checking_one)
end
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money
test "negative amounts are inflows, positive amounts are outflows to an account" do
inflow_transaction = transactions(:checking_four)
outflow_transaction = transactions(:checking_five)
assert inflow_transaction.amount < 0
assert inflow_transaction.inflow?
assert outflow_transaction.amount >= 0
assert Transaction.inflows.include? inflow_transaction
assert Transaction.outflows.include? outflow_transaction
assert outflow_transaction.outflow?
end
test "triggers sync with correct start date when transaction is set to prior date" do
prior_date = @transaction.date - 1
@transaction.update! date: prior_date
@transaction.account.expects(:sync_later).with(prior_date)
@transaction.sync_account_later
end
test "triggers sync with correct start date when transaction is set to future date" do
prior_date = @transaction.date
@transaction.update! date: @transaction.date + 1
@transaction.account.expects(:sync_later).with(prior_date)
@transaction.sync_account_later
end
test "triggers sync with correct start date when transaction deleted" do
prior_transaction = transactions(:checking_two) # 12 days ago
current_transaction = transactions(:checking_one) # 5 days ago
current_transaction.destroy!
current_transaction.account.expects(:sync_later).with(prior_transaction.date)
current_transaction.sync_account_later
end
end

View file

@ -0,0 +1,94 @@
require "application_system_test_case"
class TransactionsTest < ApplicationSystemTestCase
setup do
sign_in @user = users(:family_admin)
@test_category = @user.family.transaction_categories.create! name: "System Test Category"
@test_merchant = @user.family.transaction_merchants.create! name: "System Test Merchant"
@target_txn = @user.family.accounts.first.transactions.create! \
name: "Oldest transaction",
date: 10.years.ago.to_date,
category: @test_category,
merchant: @test_merchant,
amount: 100
visit transactions_url
end
test "can search for a transaction" do
assert_selector "h1", text: "Transactions"
within "form#transactions-search" do
fill_in "Search transactions by name", with: @target_txn.name
end
assert_selector "#" + dom_id(@target_txn), count: 1
within "#transaction-search-filters" do
assert_text @target_txn.name
end
end
test "can open filters and apply one or more" do
find("#transaction-filters-button").click
within "#transaction-filters-menu" do
check(@target_txn.account.name)
click_button "Category"
check(@test_category.name)
click_button "Apply"
end
assert_selector "#" + dom_id(@target_txn), count: 1
within "#transaction-search-filters" do
assert_text @target_txn.account.name
assert_text @target_txn.category.name
end
end
test "all filters work and empty state shows if no match" do
find("#transaction-filters-button").click
within "#transaction-filters-menu" do
click_button "Account"
check(@target_txn.account.name)
click_button "Date"
fill_in "q_start_date", with: 10.days.ago.to_date
fill_in "q_end_date", with: Date.current
click_button "Type"
assert_text "Filter by type coming soon..."
click_button "Amount"
assert_text "Filter by amount coming soon..."
click_button "Category"
check(@test_category.name)
click_button "Merchant"
check(@test_merchant.name)
click_button "Apply"
end
assert_text "No transactions found"
# Page reload doesn't affect results
visit current_url
assert_text "No transactions found"
within "ul#transaction-search-filters" do
find("li", text: @target_txn.account.name).first("a").click
find("li", text: "on or after #{10.days.ago.to_date}").first("a").click
find("li", text: "on or before #{Date.current}").first("a").click
find("li", text: @target_txn.category.name).first("a").click
find("li", text: @target_txn.merchant.name).first("a").click
end
assert_selector "#" + dom_id(@user.family.transactions.ordered.first), count: 1
end
end

View file

@ -33,7 +33,7 @@ end
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
parallelize(workers: :number_of_processors) unless ENV["DISABLE_PARALLELIZATION"]
# https://github.com/simplecov-ruby/simplecov/issues/718#issuecomment-538201587
if ENV["COVERAGE"]