diff --git a/Gemfile.lock b/Gemfile.lock index 813d69c1..32fb9a8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -121,7 +121,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (7.0.0) racc builder (3.3.0) capybara (3.40.0) diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index d9ea7d48..de0b8978 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -29,6 +29,11 @@ @apply focus:opacity-100 focus:outline-none focus:ring-0; @apply placeholder-shown:opacity-50; @apply disabled:text-gray-400; + @apply text-ellipsis overflow-hidden whitespace-nowrap; + } + + select.form-field__input { + @apply pr-8; } .form-field__radio { @@ -51,10 +56,18 @@ @apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500; } + [type='checkbox'].maybe-checkbox--light:disabled { + @apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400; + } + [type='checkbox'].maybe-checkbox--dark { @apply ring-gray-900 checked:text-white; } + [type='checkbox'].maybe-checkbox--dark:disabled { + @apply cursor-not-allowed opacity-80 ring-gray-600; + } + [type='checkbox'].maybe-checkbox--dark:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); } diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb index 6784aac6..028fd5d0 100644 --- a/app/controllers/account/transactions_controller.rb +++ b/app/controllers/account/transactions_controller.rb @@ -21,24 +21,6 @@ class Account::TransactionsController < ApplicationController redirect_back_or_to transactions_url, notice: t(".success", count: updated) end - def mark_transfers - Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .mark_transfers! - - redirect_back_or_to transactions_url, notice: t(".success") - end - - def unmark_transfers - Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .update_all marked_as_transfer: false - - redirect_back_or_to transactions_url, notice: t(".success") - end - private def bulk_delete_params params.require(:bulk_delete).permit(entry_ids: []) diff --git a/app/controllers/account/transfer_matches_controller.rb b/app/controllers/account/transfer_matches_controller.rb new file mode 100644 index 00000000..b686c6c8 --- /dev/null +++ b/app/controllers/account/transfer_matches_controller.rb @@ -0,0 +1,56 @@ +class Account::TransferMatchesController < ApplicationController + before_action :set_entry + + def new + @accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id) + @transfer_match_candidates = @entry.transfer_match_candidates + end + + def create + @transfer = build_transfer + @transfer.save! + @transfer.sync_account_later + + redirect_back_or_to transactions_path, notice: t(".success") + end + + private + def set_entry + @entry = Current.family.entries.find(params[:transaction_id]) + end + + def transfer_match_params + params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id) + end + + def build_transfer + if transfer_match_params[:method] == "new" + target_account = Current.family.accounts.find(transfer_match_params[:target_account_id]) + + missing_transaction = Account::Transaction.new( + entry: target_account.entries.build( + amount: @entry.amount * -1, + currency: @entry.currency, + date: @entry.date, + name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}", + ) + ) + + transfer = Transfer.find_or_initialize_by( + inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction, + outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction + ) + transfer.status = "confirmed" + transfer + else + target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id]) + + transfer = Transfer.find_or_initialize_by( + inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction, + outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction + ) + transfer.status = "confirmed" + transfer + end + end +end diff --git a/app/controllers/account/transfers_controller.rb b/app/controllers/account/transfers_controller.rb deleted file mode 100644 index 0aaac5c2..00000000 --- a/app/controllers/account/transfers_controller.rb +++ /dev/null @@ -1,61 +0,0 @@ -class Account::TransfersController < ApplicationController - layout :with_sidebar - - before_action :set_transfer, only: %i[destroy show update] - - def new - @transfer = Account::Transfer.new - end - - def show - end - - def create - from_account = Current.family.accounts.find(transfer_params[:from_account_id]) - to_account = Current.family.accounts.find(transfer_params[:to_account_id]) - - @transfer = Account::Transfer.build_from_accounts from_account, to_account, \ - date: transfer_params[:date], - amount: transfer_params[:amount].to_d - - if @transfer.save - @transfer.entries.each(&:sync_account_later) - redirect_to transactions_path, notice: t(".success") - else - # TODO: this is not an ideal way to handle errors and should eventually be improved. - # See: https://github.com/hotwired/turbo-rails/pull/367 - flash[:alert] = @transfer.errors.full_messages.to_sentence - redirect_to transactions_path - end - end - - def update - @transfer.update_entries!(transfer_update_params) - redirect_back_or_to transactions_url, notice: t(".success") - end - - def destroy - @transfer.destroy! - redirect_back_or_to transactions_url, notice: t(".success") - end - - private - - def set_transfer - record = Account::Transfer.find(params[:id]) - - unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) } - raise ActiveRecord::RecordNotFound - end - - @transfer = record - end - - def transfer_params - params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded) - end - - def transfer_update_params - params.require(:account_transfer).permit(:excluded, :notes) - end -end diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 918b32bb..e3b7365f 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -52,11 +52,14 @@ module EntryableResource respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") } format.turbo_stream do - render turbo_stream: turbo_stream.replace( - "header_account_entry_#{@entry.id}", - partial: "#{entryable_type.name.underscore.pluralize}/header", - locals: { entry: @entry } - ) + render turbo_stream: [ + turbo_stream.replace( + "header_account_entry_#{@entry.id}", + partial: "#{entryable_type.name.underscore.pluralize}/header", + locals: { entry: @entry } + ), + turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry }) + ] end end else diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 664c3080..f20a3304 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -6,10 +6,15 @@ class TransactionsController < ApplicationController search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological @pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50") + totals_query = search_query.incomes_and_expenses + family_currency = Current.family.currency + count_with_transfers = search_query.count + count_without_transfers = totals_query.count + @totals = { - count: search_query.select { |t| t.currency == Current.family.currency }.count, - income: search_query.income_total(Current.family.currency).abs, - expense: search_query.expense_total(Current.family.currency) + count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers, + income: totals_query.income_total(family_currency).abs, + expense: totals_query.expense_total(family_currency) } end diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb new file mode 100644 index 00000000..14c64422 --- /dev/null +++ b/app/controllers/transfers_controller.rb @@ -0,0 +1,66 @@ +class TransfersController < ApplicationController + layout :with_sidebar + + before_action :set_transfer, only: %i[destroy show update] + + def new + @transfer = Transfer.new + end + + def show + end + + def create + from_account = Current.family.accounts.find(transfer_params[:from_account_id]) + to_account = Current.family.accounts.find(transfer_params[:to_account_id]) + + @transfer = Transfer.from_accounts( + from_account: from_account, + to_account: to_account, + date: transfer_params[:date], + amount: transfer_params[:amount].to_d + ) + + if @transfer.save + @transfer.sync_account_later + + flash[:notice] = t(".success") + + respond_to do |format| + format.html { redirect_back_or_to transactions_path } + redirect_target_url = request.referer || transactions_path + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + @transfer.update!(transfer_update_params) + respond_to do |format| + format.html { redirect_back_or_to transactions_url, notice: t(".success") } + format.turbo_stream + end + end + + def destroy + @transfer.destroy! + redirect_back_or_to transactions_url, notice: t(".success") + end + + private + def set_transfer + @transfer = Transfer.find(params[:id]) + + raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family) + end + + def transfer_params + params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded) + end + + def transfer_update_params + params.require(:transfer).permit(:notes, :status) + end +end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 359a241a..7fb8a6a1 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -3,10 +3,6 @@ module Account::EntriesHelper "account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}" end - def unconfirmed_transfer?(entry) - entry.marked_as_transfer? && entry.transfer.nil? - end - def transfer_entries(entries) transfers = entries.select { |e| e.transfer_id.present? } transfers.map(&:transfer).uniq @@ -18,8 +14,19 @@ module Account::EntriesHelper yield grouped_entries end + next if content.blank? + render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: } - end.join.html_safe + end.compact.join.html_safe + end + + def entry_name_detailed(entry) + [ + entry.date, + format_money(entry.amount_money), + entry.account.name, + entry.display_name + ].join(" • ") end private diff --git a/app/helpers/account/transfers_helper.rb b/app/helpers/account/transfers_helper.rb deleted file mode 100644 index ba7a95ae..00000000 --- a/app/helpers/account/transfers_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module Account::TransfersHelper -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 85aca138..0bead33e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -67,9 +67,9 @@ module ApplicationHelper render partial: "shared/drawer", locals: { content:, reload_on_close: } end - def disclosure(title, &block) + def disclosure(title, default_open: true, &block) content = capture &block - render partial: "shared/disclosure", locals: { title: title, content: content } + render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open } end def sidebar_link_to(name, path, options = {}) diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index c58713d4..9250f1d7 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -5,6 +5,24 @@ module CategoriesHelper color: Category::UNCATEGORIZED_COLOR end + def transfer_category + Category.new \ + name: "⇄ Transfer", + color: Category::TRANSFER_COLOR + end + + def payment_category + Category.new \ + name: "→ Payment", + color: Category::PAYMENT_COLOR + end + + def trade_category + Category.new \ + name: "Trade", + color: Category::TRADE_COLOR + end + def family_categories [ null_category ].concat(Current.family.categories.alphabetically) end diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index 023e97da..22b0a59a 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -99,7 +99,9 @@ export default class extends Controller { } _rowsForGroup(group) { - return this.rowTargets.filter((row) => group.contains(row)); + return this.rowTargets.filter( + (row) => group.contains(row) && !row.disabled, + ); } _addToSelection(idToAdd) { @@ -115,7 +117,9 @@ export default class extends Controller { } _selectAll() { - this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id); + this.selectedIdsValue = this.rowTargets + .filter((t) => !t.disabled) + .map((t) => t.dataset.id); } _updateView = () => { diff --git a/app/javascript/controllers/transfer_match_controller.js b/app/javascript/controllers/transfer_match_controller.js new file mode 100644 index 00000000..eda4a90c --- /dev/null +++ b/app/javascript/controllers/transfer_match_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="transfer-match" +export default class extends Controller { + static targets = ["newSelect", "existingSelect"]; + + update(event) { + if (event.target.value === "new") { + this.newSelectTarget.classList.remove("hidden"); + this.existingSelectTarget.classList.add("hidden"); + } else { + this.newSelectTarget.classList.add("hidden"); + this.existingSelectTarget.classList.remove("hidden"); + } + } +} diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 8d6a8f40..b9fd5534 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -30,7 +30,15 @@ class Account::Entry < ApplicationRecord ) } - scope :without_transfers, -> { where(marked_as_transfer: false) } + # All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included) + scope :incomes_and_expenses, -> { + joins( + 'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id + LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id' + ) + .where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'") + } + scope :with_converted_amount, ->(currency) { # Join with exchange rates to convert the amount to the given currency # If no rate is available, exclude the transaction from the results @@ -59,6 +67,15 @@ class Account::Entry < ApplicationRecord enriched_name.presence || name end + def transfer_match_candidates + account.family.entries + .where.not(account_id: account_id) + .where.not(id: id) + .where(amount: -amount) + .where(currency: currency) + .where(date: (date - 4.days)..(date + 4.days)) + end + class << self def search(params) Account::EntrySearch.new(params).build_query(all) @@ -98,13 +115,6 @@ class Account::Entry < ApplicationRecord select("*").from(rolling_totals).where("date >= ?", period.date_range.first) end - def mark_transfers! - update_all marked_as_transfer: true - - # Attempt to "auto match" and save a transfer if 2 transactions selected - Account::Transfer.new(entries: all).save if all.count == 2 - end - def bulk_update!(bulk_update_params) bulk_attributes = { date: bulk_update_params[:date], @@ -128,7 +138,7 @@ class Account::Entry < ApplicationRecord end def income_total(currency = "USD") - total = without_transfers.account_transactions.includes(:entryable) + total = account_transactions.includes(:entryable).incomes_and_expenses .where("account_entries.amount <= 0") .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } .sum @@ -137,29 +147,12 @@ class Account::Entry < ApplicationRecord end def expense_total(currency = "USD") - total = without_transfers.account_transactions.includes(:entryable) + total = account_transactions.includes(:entryable).incomes_and_expenses .where("account_entries.amount > 0") .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } .sum Money.new(total, currency) end - - private - def entryable_search(params) - entryable_ids = [] - entryable_search_performed = false - - Account::Entryable::TYPES.map(&:constantize).each do |entryable| - next unless entryable.requires_search?(params) - - entryable_search_performed = true - entryable_ids += entryable.search(params).pluck(:id) - end - - return nil unless entryable_search_performed - - entryable_ids - end end end diff --git a/app/models/account/entry_search.rb b/app/models/account/entry_search.rb index c561765b..962cb5c0 100644 --- a/app/models/account/entry_search.rb +++ b/app/models/account/entry_search.rb @@ -6,8 +6,8 @@ class Account::EntrySearch attribute :amount, :string attribute :amount_operator, :string attribute :types, :string - attribute :accounts, :string - attribute :account_ids, :string + attribute :accounts, array: true + attribute :account_ids, array: true attribute :start_date, :string attribute :end_date, :string @@ -27,8 +27,6 @@ class Account::EntrySearch query = query.where("account_entries.date <= ?", end_date) if end_date.present? if types.present? - query = query.where(marked_as_transfer: false) unless types.include?("transfer") - if types.include?("income") && !types.include?("expense") query = query.where("account_entries.amount < 0") elsif types.include?("expense") && !types.include?("income") diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 5b2e4aba..27cd139d 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -5,6 +5,8 @@ class Account::Syncer end def run + Transfer.auto_match_for_account(account) + holdings = sync_holdings balances = sync_balances(holdings) account.reload diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index e62947f7..8e0f9ad7 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -4,6 +4,13 @@ class Account::TradeBuilder attr_accessor :account, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id + attr_reader :buildable + + def initialize(attributes = {}) + super + @buildable = set_buildable + end + def save buildable.save end @@ -17,7 +24,7 @@ class Account::TradeBuilder end private - def buildable + def set_buildable case type when "buy", "sell" build_trade @@ -55,9 +62,9 @@ class Account::TradeBuilder from_account = type == "withdrawal" ? account : transfer_account to_account = type == "withdrawal" ? transfer_account : account - Account::Transfer.build_from_accounts( - from_account, - to_account, + Transfer.from_accounts( + from_account: from_account, + to_account: to_account, date: date, amount: signed_amount ) @@ -67,7 +74,6 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - marked_as_transfer: true, entryable: Account::Transaction.new ) end diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index afe5a568..91fc0420 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -6,6 +6,9 @@ class Account::Transaction < ApplicationRecord has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings + has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :restrict_with_exception + has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :restrict_with_exception + accepts_nested_attributes_for :taggings, allow_destroy: true scope :active, -> { where(excluded: false) } @@ -15,4 +18,12 @@ class Account::Transaction < ApplicationRecord Account::TransactionSearch.new(params).build_query(all) end end + + def transfer + transfer_as_inflow || transfer_as_outflow + end + + def transfer? + transfer.present? && transfer.status != "rejected" + end end diff --git a/app/models/account/transaction_search.rb b/app/models/account/transaction_search.rb index f61fae69..12884302 100644 --- a/app/models/account/transaction_search.rb +++ b/app/models/account/transaction_search.rb @@ -18,6 +18,11 @@ class Account::TransactionSearch def build_query(scope) query = scope + if types.present? && types.exclude?("transfer") + query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id") + .where("transfers.id IS NULL") + end + if categories.present? if categories.exclude?("Uncategorized") query = query diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb deleted file mode 100644 index ea908413..00000000 --- a/app/models/account/transfer.rb +++ /dev/null @@ -1,113 +0,0 @@ -class Account::Transfer < ApplicationRecord - has_many :entries, dependent: :destroy - - validate :net_zero_flows, if: :single_currency_transfer? - validate :transaction_count, :from_different_accounts, :all_transactions_marked - - def date - outflow_transaction&.date - end - - def amount_money - entries.first&.amount_money&.abs || Money.new(0) - end - - def from_name - from_account&.name || I18n.t("account/transfer.from_fallback_name") - end - - def to_name - to_account&.name || I18n.t("account/transfer.to_fallback_name") - end - - def name - I18n.t("account/transfer.name", from_account: from_name, to_account: to_name) - end - - def from_account - outflow_transaction&.account - end - - def to_account - inflow_transaction&.account - end - - def inflow_transaction - entries.find { |e| e.amount.negative? } - end - - def outflow_transaction - entries.find { |e| e.amount.positive? } - end - - def update_entries!(params) - transaction do - entries.each do |entry| - entry.update!(params) - end - end - end - - def sync_account_later - entries.each(&:sync_account_later) - end - - class << self - def build_from_accounts(from_account, to_account, date:, amount:) - outflow = from_account.entries.build \ - amount: amount.abs, - currency: from_account.currency, - date: date, - name: "Transfer to #{to_account.name}", - marked_as_transfer: true, - entryable: Account::Transaction.new - - # Attempt to convert the amount to the to_account's currency. If the conversion fails, - # use the original amount. - converted_amount = begin - Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency) - rescue Money::ConversionError - Money.new(amount.abs, from_account.currency) - end - - inflow = to_account.entries.build \ - amount: converted_amount.amount * -1, - currency: converted_amount.currency.iso_code, - date: date, - name: "Transfer from #{from_account.name}", - marked_as_transfer: true, - entryable: Account::Transaction.new - - new entries: [ outflow, inflow ] - end - end - - private - - def single_currency_transfer? - entries.map { |e| e.currency }.uniq.size == 1 - end - - def transaction_count - unless entries.size == 2 - errors.add :entries, :must_have_exactly_2_entries - end - end - - def from_different_accounts - accounts = entries.map { |e| e.account_id }.uniq - errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size - end - - def net_zero_flows - unless entries.sum(&:amount).zero? - errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero - end - end - - def all_transactions_marked - unless entries.all?(&:marked_as_transfer) - errors.add :entries, :must_be_marked_as_transfer - end - end -end diff --git a/app/models/category.rb b/app/models/category.rb index 6f50070b..8d0c24b6 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -17,6 +17,9 @@ class Category < ApplicationRecord COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] UNCATEGORIZED_COLOR = "#737373" + TRANSFER_COLOR = "#444CE7" + PAYMENT_COLOR = "#db5a54" + TRADE_COLOR = "#e99537" class Group attr_reader :category, :subcategories diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 8041dea6..2f65b9da 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -36,6 +36,8 @@ class Demo::Generator create_car_and_loan! create_other_accounts! + create_transfer_transactions! + puts "accounts created" puts "Demo data loaded successfully!" end @@ -49,12 +51,14 @@ class Demo::Generator family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id family = Family.find_by(id: family_id) + Transfer.destroy_all family.destroy! if family Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload) end def clear_data! + Transfer.destroy_all InviteCode.destroy_all User.find_by_email("user@maybe.local")&.destroy ExchangeRate.destroy_all @@ -177,6 +181,40 @@ class Demo::Generator end end + def create_transfer_transactions! + checking = family.accounts.find_by(name: "Chase Checking") + credit_card = family.accounts.find_by(name: "Chase Credit Card") + investment = family.accounts.find_by(name: "Robinhood") + + create_transaction!( + account: checking, + date: 1.day.ago.to_date, + amount: 100, + name: "Credit Card Payment" + ) + + create_transaction!( + account: credit_card, + date: 1.day.ago.to_date, + amount: -100, + name: "Credit Card Payment" + ) + + create_transaction!( + account: checking, + date: 3.days.ago.to_date, + amount: 500, + name: "Transfer to investment" + ) + + create_transaction!( + account: investment, + date: 2.days.ago.to_date, + amount: -500, + name: "Transfer from checking" + ) + end + def load_securities! # Create an unknown security to simulate edge cases Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN" diff --git a/app/models/family.rb b/app/models/family.rb index 69ac5eb7..601692ed 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -82,7 +82,9 @@ class Family < ApplicationRecord def snapshot_account_transactions period = Period.last_30_days - results = accounts.active.joins(:entries) + results = accounts.active + .joins(:entries) + .joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)") .select( "accounts.*", "COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending", @@ -90,8 +92,7 @@ class Family < ApplicationRecord ) .where("account_entries.date >= ?", period.date_range.begin) .where("account_entries.date <= ?", period.date_range.end) - .where("account_entries.marked_as_transfer = ?", false) - .where("account_entries.entryable_type = ?", "Account::Transaction") + .where("transfers.id IS NULL") .group("accounts.id") .having("SUM(ABS(account_entries.amount)) > 0") .to_a @@ -110,9 +111,7 @@ class Family < ApplicationRecord end def snapshot_transactions - candidate_entries = entries.account_transactions.without_transfers.excluding( - entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] }) - ) + candidate_entries = entries.account_transactions.incomes_and_expenses rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days) spending = [] diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 5772f821..1fdf40ee 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord t.amount = plaid_txn.amount t.currency = plaid_txn.iso_currency_code t.date = plaid_txn.date - t.marked_as_transfer = transfer?(plaid_txn) t.entryable = Account::Transaction.new( category: get_category(plaid_txn.personal_finance_category.primary), merchant: get_merchant(plaid_txn.merchant_name) diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb index fd207116..8c1d9805 100644 --- a/app/models/plaid_investment_sync.rb +++ b/app/models/plaid_investment_sync.rb @@ -31,7 +31,6 @@ class PlaidInvestmentSync t.amount = transaction.amount t.currency = transaction.iso_currency_code t.date = transaction.date - t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) t.entryable = Account::Transaction.new end else diff --git a/app/models/transfer.rb b/app/models/transfer.rb new file mode 100644 index 00000000..265c516b --- /dev/null +++ b/app/models/transfer.rb @@ -0,0 +1,139 @@ +class Transfer < ApplicationRecord + belongs_to :inflow_transaction, class_name: "Account::Transaction" + belongs_to :outflow_transaction, class_name: "Account::Transaction" + + enum :status, { pending: "pending", confirmed: "confirmed", rejected: "rejected" } + + validate :transfer_has_different_accounts + validate :transfer_has_opposite_amounts + validate :transfer_within_date_range + + class << self + def from_accounts(from_account:, to_account:, date:, amount:) + # Attempt to convert the amount to the to_account's currency. + # If the conversion fails, use the original amount. + converted_amount = begin + Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency) + rescue Money::ConversionError + Money.new(amount.abs, from_account.currency) + end + + new( + inflow_transaction: Account::Transaction.new( + entry: to_account.entries.build( + amount: converted_amount.amount.abs * -1, + currency: converted_amount.currency.iso_code, + date: date, + name: "Transfer from #{from_account.name}", + entryable: Account::Transaction.new + ) + ), + outflow_transaction: Account::Transaction.new( + entry: from_account.entries.build( + amount: amount.abs, + currency: from_account.currency, + date: date, + name: "Transfer to #{to_account.name}", + entryable: Account::Transaction.new + ) + ), + status: "confirmed" + ) + end + + def auto_match_for_account(account) + matches = account.entries.account_transactions.joins(" + JOIN account_entries ae2 ON + account_entries.amount = -ae2.amount AND + account_entries.currency = ae2.currency AND + account_entries.account_id <> ae2.account_id AND + ABS(account_entries.date - ae2.date) <= 4 + ").select( + "account_entries.id", + "account_entries.entryable_id AS e1_entryable_id", + "ae2.entryable_id AS e2_entryable_id", + "account_entries.amount AS e1_amount", + "ae2.amount AS e2_amount" + ) + + Transfer.transaction do + matches.each do |match| + inflow = match.e1_amount.negative? ? match.e1_entryable_id : match.e2_entryable_id + outflow = match.e1_amount.negative? ? match.e2_entryable_id : match.e1_entryable_id + + # Skip all rejected, or already matched transfers + next if Transfer.exists?( + inflow_transaction_id: inflow, + outflow_transaction_id: outflow + ) + + Transfer.create!( + inflow_transaction_id: inflow, + outflow_transaction_id: outflow + ) + end + end + end + end + + def sync_account_later + inflow_transaction.entry.sync_account_later + outflow_transaction.entry.sync_account_later + end + + def belongs_to_family?(family) + family.transactions.include?(inflow_transaction) + end + + def to_account + inflow_transaction.entry.account + end + + def from_account + outflow_transaction.entry.account + end + + def amount_abs + inflow_transaction.entry.amount_money.abs + end + + def name + if payment? + I18n.t("transfer.payment_name", to_account: to_account.name) + else + I18n.t("transfer.name", to_account: to_account.name) + end + end + + def payment? + to_account.liability? + end + + private + def transfer_has_different_accounts + return unless inflow_transaction.present? && outflow_transaction.present? + errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account + end + + def transfer_has_opposite_amounts + return unless inflow_transaction.present? && outflow_transaction.present? + + inflow_amount = inflow_transaction.entry.amount + outflow_amount = outflow_transaction.entry.amount + + if inflow_transaction.entry.currency == outflow_transaction.entry.currency + # For same currency, amounts must be exactly opposite + errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0 + else + # For different currencies, just check the signs are opposite + errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive? + end + end + + def transfer_within_date_range + return unless inflow_transaction.present? && outflow_transaction.present? + + date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs + errors.add(:base, :must_be_within_date_range) if date_diff > 4 + end +end diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 31197675..1ac6de61 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -84,7 +84,7 @@
- <%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page]) %> + <%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page], tab: params[:tab]) %>
<% end %> diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index aaaf6900..c8dae4d3 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -22,7 +22,7 @@ { label: t(".type"), selected: type }, { data: { action: "trade-form#changeType", - trade_form_url_param: new_account_trade_path(account_id: entry.account_id), + trade_form_url_param: new_account_trade_path(account_id: entry.account&.id || entry.account_id), trade_form_key_param: "type", }} %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index 8c5c924e..517dc64d 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -3,7 +3,7 @@ <% trade, account = entry.account_trade, entry.account %>
text-sm font-medium p-4"> -
+
<% if selectable %> <%= check_box_tag dom_id(entry, "selection"), class: "maybe-checkbox maybe-checkbox--light", @@ -30,6 +30,10 @@
+
+ <%= render "categories/badge", category: trade_category %> +
+
<%= content_tag :p, format_money(-entry.amount_money), diff --git a/app/views/account/transactions/_form.html.erb b/app/views/account/transactions/_form.html.erb index 47a0f01c..34e32236 100644 --- a/app/views/account/transactions/_form.html.erb +++ b/app/views/account/transactions/_form.html.erb @@ -8,7 +8,7 @@
<%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %> <%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %> - <%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %> + <%= link_to new_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %> <%= lucide_icon "arrow-right-left", class: "w-5 h-5" %> <%= tag.span t(".transfer") %> <% end %> diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb index af819326..401157f1 100644 --- a/app/views/account/transactions/_header.html.erb +++ b/app/views/account/transactions/_header.html.erb @@ -12,7 +12,7 @@ - <% if entry.marked_as_transfer? %> + <% if entry.account_transaction.transfer? %> <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> <% end %>
diff --git a/app/views/account/transactions/_selection_bar.html.erb b/app/views/account/transactions/_selection_bar.html.erb index 4fae27fa..1c38f869 100644 --- a/app/views/account/transactions/_selection_bar.html.erb +++ b/app/views/account/transactions/_selection_bar.html.erb @@ -8,26 +8,6 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> - <%= form_with url: mark_transfers_account_transactions_path, - scope: "bulk_update", - data: { - turbo_frame: "_top", - turbo_confirm: { - title: t(".mark_transfers"), - body: t(".mark_transfers_message"), - accept: t(".mark_transfers_confirm"), - } - } do |f| %> - - <% end %> - <%= link_to bulk_edit_account_transactions_path, class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Edit", diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index dfe0955a..6b963050 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,10 +1,11 @@ <%# locals: (entry:, selectable: true, balance_trend: nil) %> <% transaction, account = entry.account_transaction, entry.account %> -
text-sm font-medium p-4"> -
+
+
"> <% if selectable %> <%= check_box_tag dom_id(entry, "selection"), + disabled: entry.account_transaction.transfer?, class: "maybe-checkbox maybe-checkbox--light", data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> <% end %> @@ -18,49 +19,44 @@ <% end %>
- <% if entry.new_record? %> - <%= content_tag :p, entry.display_name %> - <% else %> - <%= link_to entry.display_name, - entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), +
+
+ <% if entry.new_record? %> + <%= content_tag :p, entry.display_name %> + <% else %> + <%= link_to entry.display_name, + entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> - <% end %> + <% end %> + + <% if entry.excluded %> + (excluded from averages)"> + <%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %> + + <% end %> + + <% if entry.account_transaction.transfer? %> + <%= render "account/transactions/transfer_match", entry: entry %> + <% end %> +
+ +
+ <% if entry.account_transaction.transfer? %> + <%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %> + <% else %> + <%= link_to entry.account.name, account_path(entry.account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %> + <% end %> +
+
<% end %>
- - <% if unconfirmed_transfer?(entry) %> - <%= render "account/transfers/transfer_toggle", entry: entry %> - <% end %>
- <% if entry.transfer.present? %> - <% unless balance_trend %> -
- <% end %> - -
- <%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.amount.positive? %> -
- <% else %> -
- <%= render "categories/menu", transaction: transaction %> -
- - <% unless balance_trend %> - <%= tag.div class: "col-span-2 overflow-hidden truncate" do %> - <% if entry.new_record? %> - <%= tag.p account.name %> - <% else %> - <%= link_to account.name, - account_path(account, tab: "transactions"), - data: { turbo_frame: "_top" }, - class: "hover:underline" %> - <% end %> - <% end %> - <% end %> - <% end %> +
+ <%= render "account/transactions/transaction_category", entry: entry %> +
<%= content_tag :p, diff --git a/app/views/account/transactions/_transaction_category.html.erb b/app/views/account/transactions/_transaction_category.html.erb new file mode 100644 index 00000000..ab0b33c8 --- /dev/null +++ b/app/views/account/transactions/_transaction_category.html.erb @@ -0,0 +1,9 @@ +<%# locals: (entry:) %> + +
"> + <% if entry.account_transaction.transfer? %> + <%= render "categories/badge", category: entry.account_transaction.transfer.payment? ? payment_category : transfer_category %> + <% else %> + <%= render "categories/menu", transaction: entry.account_transaction %> + <% end %> +
diff --git a/app/views/account/transactions/_transfer_match.html.erb b/app/views/account/transactions/_transfer_match.html.erb new file mode 100644 index 00000000..7175e02e --- /dev/null +++ b/app/views/account/transactions/_transfer_match.html.erb @@ -0,0 +1,27 @@ +<%# locals: (entry:) %> + +
" class="flex items-center gap-1"> + <% if entry.account_transaction.transfer.confirmed? %> + is confirmed"> + <%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %> + + <% elsif entry.account_transaction.transfer.pending? %> + + Auto-matched + + + <%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "confirmed" }), + method: :patch, + class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", + title: "Confirm match" do %> + <%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %> + <% end %> + + <%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }), + method: :patch, + class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", + title: "Reject match" do %> + <%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %> + <% end %> + <% end %> +
diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index b512b43a..5658bd65 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -19,7 +19,7 @@ max: Date.current, "data-auto-submit-form-target": "auto" %> - <% unless @entry.marked_as_transfer? %> + <% unless @entry.account_transaction.transfer? %>
<%= f.select :nature, [["Expense", "outflow"], ["Income", "inflow"]], @@ -32,27 +32,7 @@ min: 0, value: @entry.amount.abs %>
- <% end %> - <%= f.select :account, - options_for_select( - Current.family.accounts.alphabetically.pluck(:name, :id), - @entry.account_id - ), - { label: t(".account_label") }, - { disabled: true } %> - <% end %> -
- <% end %> - - - <%= disclosure t(".details") do %> -
- <%= styled_form_with model: @entry, - url: account_transaction_path(@entry), - class: "space-y-2", - data: { controller: "auto-submit-form" } do |f| %> - <% unless @entry.marked_as_transfer? %> <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, Current.family.categories.alphabetically, @@ -60,6 +40,30 @@ { label: t(".category_label"), class: "text-gray-400", include_blank: t(".uncategorized") }, "data-auto-submit-form-target": "auto" %> + <% end %> + <% end %> + + <% end %> +
+ <% end %> + + + <%= disclosure t(".details"), default_open: false do %> +
+ <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), + class: "space-y-2", + data: { controller: "auto-submit-form" } do |f| %> + <% unless @entry.account_transaction.transfer? %> + <%= f.select :account, + options_for_select( + Current.family.accounts.alphabetically.pluck(:name, :id), + @entry.account_id + ), + { label: t(".account_label") }, + { disabled: true } %> + + <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :merchant_id, Current.family.merchants.alphabetically, @@ -94,15 +98,15 @@ <%= disclosure t(".settings") do %>
- + <%= styled_form_with model: @entry, url: account_transaction_path(@entry), class: "p-3", data: { controller: "auto-submit-form" } do |f| %> -
+
-

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

-

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

+

One-time <%= @entry.amount.negative? ? "Income" : "Expense" %>

+

One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.

@@ -115,6 +119,18 @@
<% end %> +
+
+

Transfer or Debt Payment?

+

Transfers and payments are special types of transactions that indicate money movement between 2 accounts.

+
+ + <%= link_to new_account_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %> + <%= lucide_icon "arrow-left-right", class: "w-4 h-4 shrink-0" %> + Open matcher + <% end %> +
+
diff --git a/app/views/account/transfer_matches/_matching_fields.html.erb b/app/views/account/transfer_matches/_matching_fields.html.erb new file mode 100644 index 00000000..cc087123 --- /dev/null +++ b/app/views/account/transfer_matches/_matching_fields.html.erb @@ -0,0 +1,44 @@ +<%# locals: (form:, entry:, candidates:, accounts:) %> + +<% if candidates.any? %> +
+

+ Select a method for matching your transactions. +

+ + <%= form.select :method, + [ + ["Match existing transaction (recommended)", "existing"], + ["Create new transaction", "new"] + ], + { selected: "existing", label: "Matching method" }, + data: { action: "change->transfer-match#update" } %> + +
+ <%= form.select :matched_entry_id, + candidates.map { |entry| + [entry_name_detailed(entry), entry.id] + }, + { label: "Matching transaction" } %> +
+ + +
+<% else %> +

+ We couldn't find any transactions to match from your other accounts. + Please select an account and we will create a new inflow transaction for you. +

+ + <%= form.hidden_field :method, value: "new" %> + +
+ <%= form.select :target_account_id, + accounts.map { |account| [account.name, account.id] }, + { label: "Target account" } %> +
+<% end %> diff --git a/app/views/account/transfer_matches/new.html.erb b/app/views/account/transfer_matches/new.html.erb new file mode 100644 index 00000000..49a450bf --- /dev/null +++ b/app/views/account/transfer_matches/new.html.erb @@ -0,0 +1,60 @@ +<%= modal_form_wrapper title: "Match transfer or payment" do %> + <%= styled_form_with( + url: account_transaction_transfer_match_path(@entry), + scope: :transfer_match, + class: "space-y-8", + data: { turbo_frame: :_top } + ) do |f| %> +
+
+

+ <%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %> +

+ + <% if @entry.amount.positive? %> + <%= f.select( + :entry_id, + [[entry_name_detailed(@entry), @entry.id]], + { + label: "Outflow transaction", + selected: @entry.id, + }, + disabled: true + ) %> + <% else %> + <%= render "account/transfer_matches/matching_fields", + form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %> + <% end %> +
+
+ +
+ <%= lucide_icon "arrow-down", class: "w-5 h-5" %> +
+ +
+
+

+ <%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %> +

+ + <% if @entry.amount.negative? %> + <%= f.select( + :entry_id, + [[entry_name_detailed(@entry), @entry.id]], + { + label: "Inflow transaction", + selected: @entry.id, + }, + disabled: true + ) %> + <% else %> + <%= render "account/transfer_matches/matching_fields", + form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %> + <% end %> +
+
+ + <%= f.submit "Create transfer match", data: { turbo_submits_with: "Saving..."} %> + <% end %> +<% end %> diff --git a/app/views/account/transfers/_account_logos.html.erb b/app/views/account/transfers/_account_logos.html.erb deleted file mode 100644 index 2d668e33..00000000 --- a/app/views/account/transfers/_account_logos.html.erb +++ /dev/null @@ -1,25 +0,0 @@ -<%# locals: (transfer:, outflow: false) %> - -
- <% if outflow %> - <%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %> - <%= circle_logo(transfer.from_name[0].upcase, size: "sm") %> - <% end %> - - <%= lucide_icon "arrow-right", class: "text-gray-500 w-4 h-4" %> - - <%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %> - <%= circle_logo(transfer.to_name[0].upcase, size: "sm") %> - <% end %> - <% else %> - <%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %> - <%= circle_logo(transfer.to_name[0].upcase, size: "sm") %> - <% end %> - - <%= lucide_icon "arrow-left", class: "text-gray-500 w-4 h-4" %> - - <%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %> - <%= circle_logo(transfer.from_name[0].upcase, size: "sm") %> - <% end %> - <% end %> -
diff --git a/app/views/account/transfers/_transfer_toggle.html.erb b/app/views/account/transfers/_transfer_toggle.html.erb deleted file mode 100644 index 233b11e7..00000000 --- a/app/views/account/transfers/_transfer_toggle.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%# locals: (entry:) %> - -<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: { - turbo_confirm: { - title: t(".remove_transfer"), - body: t(".remove_transfer_body"), - accept: t(".remove_transfer_confirm"), - }, - turbo_frame: "_top" - } do |f| %> - <%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %> - <%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %> - <%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %> - <%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %> - <% end %> -<% end %> diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index 3e34cfba..fb74b750 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -12,7 +12,7 @@ <% end %>
- <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %> + <%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %> <%= lucide_icon icon, class: "w-4 h-4" %> <% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 290c5be5..86dc7038 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page]) do %> +<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page], tab: params[:tab]) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/application/_pagination.html.erb b/app/views/application/_pagination.html.erb index f4548512..13da2cd9 100644 --- a/app/views/application/_pagination.html.erb +++ b/app/views/application/_pagination.html.erb @@ -3,7 +3,7 @@
<% if pagy.prev %> - <%= link_to custom_pagy_url_for(pagy, pagy.prev, current_path: current_path), + <%= link_to custom_pagy_url_for(pagy, pagy.prev, current_path: current_path), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700", data: (current_path ? { turbo_frame: "_top" } : {}) do %> <%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %> @@ -17,13 +17,13 @@
<% pagy.series.each do |series_item| %> <% if series_item.is_a?(Integer) %> - <%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path), + <%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path), class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700", data: (current_path ? { turbo_frame: "_top" } : {}) do %> <%= series_item %> <% end %> <% elsif series_item.is_a?(String) %> - <%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path), + <%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path), class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900", data: (current_path ? { turbo_frame: "_top" } : {}) do %> <%= series_item %> @@ -35,7 +35,7 @@
<% if pagy.next %> - <%= link_to custom_pagy_url_for(pagy, pagy.next, current_path: current_path), + <%= link_to custom_pagy_url_for(pagy, pagy.next, current_path: current_path), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700", data: (current_path ? { turbo_frame: "_top" } : {}) do %> <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index 1b4c399b..b6c6480c 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -9,4 +9,5 @@ color: <%= category.color %>;"> <%= category.name %> +
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 082cd836..6df124ee 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -29,16 +29,8 @@

- <%= link_to new_category_path(transaction_id: @transaction), - class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100", - data: { turbo_frame: "modal" } do %> - <%= lucide_icon "plus", class: "w-5 h-5" %> - - <%= t(".add_new") %> - <% end %> - <% if @transaction.category %> - <%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry), + <%= button_to account_transaction_path(@transaction.entry), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } }, @@ -48,6 +40,32 @@ <%= t(".clear") %> <% end %> <% end %> + + <%= link_to new_account_transaction_transfer_match_path(@transaction.entry), + class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100", + data: { turbo_frame: "modal" } do %> + <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> + +

Match transfer/payment

+ <% end %> + +
+
+ <%= form_with url: account_transaction_path(@transaction.entry), + method: :patch, + data: { controller: "auto-submit-form" } do |f| %> + <%= f.hidden_field "account_entry[excluded]", value: !@transaction.entry.excluded %> + <%= f.check_box "account_entry[excluded]", + checked: @transaction.entry.excluded, + class: "maybe-checkbox maybe-checkbox--light", + data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %> + <% end %> +
+ +

One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %>

+ + <%= lucide_icon "asterisk", class: "w-5 h-5 shrink-0 text-orange-500 ml-auto" %> +
<% end %> diff --git a/app/views/pages/early_access.html.erb b/app/views/pages/early_access.html.erb index 6ec820e1..cd86b0c3 100644 --- a/app/views/pages/early_access.html.erb +++ b/app/views/pages/early_access.html.erb @@ -10,7 +10,6 @@ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> - <%= hotwire_livereload_tags if Rails.env.development? %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb index e0e69e01..11b8d26a 100644 --- a/app/views/shared/_modal_form.html.erb +++ b/app/views/shared/_modal_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (title:, content:, subtitle: nil) %> <%= modal do %> -
+

<%= title %>

diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 4008af8e..37d82f29 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -17,20 +17,24 @@ <% if @transaction_entries.present? %>
-
+
<%= check_box_tag "selection_entry", class: "maybe-checkbox maybe-checkbox--light", data: { action: "bulk-select#togglePageSelection" } %> -

transaction

+

transaction

category

-

account

amount

<%= entries_by_date(@transaction_entries, totals: true) do |entries| %> - <%= render entries %> + <%# Render transfers by selecting one side of the transfer (to prevent double-rendering the same transfer across date groups) %> + <%= render partial: "transfers/transfer", + collection: entries.select { |e| e.account_transaction.transfer? && e.account_transaction.transfer_as_outflow.present? }.map { |e| e.account_transaction.transfer_as_outflow } %> + + <%# Render regular entries %> + <%= render partial: "account/entries/entry", collection: entries.reject { |e| e.account_transaction.transfer? } %> <% end %>
diff --git a/app/views/transfers/_account_links.html.erb b/app/views/transfers/_account_links.html.erb new file mode 100644 index 00000000..a71b7de0 --- /dev/null +++ b/app/views/transfers/_account_links.html.erb @@ -0,0 +1,7 @@ +<%# locals: (transfer:, is_inflow: false) %> +
+ <% first_account, second_account = is_inflow ? [transfer.to_account, transfer.from_account] : [transfer.from_account, transfer.to_account] %> + <%= link_to first_account.name, account_path(first_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %> + <%= lucide_icon is_inflow ? "arrow-left" : "arrow-right", class: "w-4 h-4 shrink-0" %> + <%= link_to second_account.name, account_path(second_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %> +
diff --git a/app/views/account/transfers/_form.html.erb b/app/views/transfers/_form.html.erb similarity index 92% rename from app/views/account/transfers/_form.html.erb rename to app/views/transfers/_form.html.erb index b55544a7..a08cbcae 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/transfers/_form.html.erb @@ -29,7 +29,7 @@ <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %> - <%= f.date_field :date, value: transfer.date || Date.current, label: t(".date"), required: true, max: Date.current %> + <%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/transfers/_transfer.html.erb b/app/views/transfers/_transfer.html.erb new file mode 100644 index 00000000..05b8db3b --- /dev/null +++ b/app/views/transfers/_transfer.html.erb @@ -0,0 +1,77 @@ +<%# locals: (transfer:) %> + +<%= turbo_frame_tag dom_id(transfer) do %> +
+
+ <%= check_box_tag dom_id(transfer), + disabled: true, + class: "maybe-checkbox maybe-checkbox--light" %> + +
+ <%= content_tag :div, class: ["flex items-center gap-2"] do %> + <%= render "shared/circle_logo", name: transfer.name, size: "sm" %> + +
+
+ +
+ <%= link_to transfer.name, + transfer_path(transfer), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "hover:underline hover:text-gray-800" %> + + <% if transfer.status == "confirmed" %> + is confirmed"> + <%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %> + + <% elsif transfer.status == "rejected" %> + + Rejected + + <% else %> + + Auto-matched + + + <%= button_to transfer_path(transfer, transfer: { status: "confirmed" }), + method: :patch, + class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", + title: "Confirm match" do %> + <%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %> + <% end %> + + <%= button_to transfer_path(transfer, transfer: { status: "rejected" }), + method: :patch, + class: "text-gray-500 hover:text-gray-800 flex items-center justify-center", + title: "Reject match" do %> + <%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %> + <% end %> + <% end %> +
+ +
+
+ <%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %> + <%= lucide_icon "arrow-left-right", class: "w-4 h-4" %> + <%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %> +
+
+
+
+ <% end %> +
+
+ +
+ <%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %> +
+ +
+

+ + +/- <%= format_money(transfer.amount_abs) %> + +

+
+
+<% end %> diff --git a/app/views/account/transfers/new.html.erb b/app/views/transfers/new.html.erb similarity index 100% rename from app/views/account/transfers/new.html.erb rename to app/views/transfers/new.html.erb diff --git a/app/views/account/transfers/show.html.erb b/app/views/transfers/show.html.erb similarity index 63% rename from app/views/account/transfers/show.html.erb rename to app/views/transfers/show.html.erb index 53e37ecf..f8307997 100644 --- a/app/views/account/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -3,11 +3,11 @@

- <%= format_money @transfer.amount_money %> + <%= format_money @transfer.amount_abs %> - <%= @transfer.amount_money.currency.iso_code %> + <%= @transfer.amount_abs.currency.iso_code %>

@@ -25,21 +25,21 @@
-
To
+
From
- <%= render "accounts/logo", account: @transfer.inflow_transaction.account, size: "sm" %> - <%= @transfer.to_name %> + <%= render "accounts/logo", account: @transfer.from_account, size: "sm" %> + <%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
Date
-
<%= l(@transfer.date, format: :long) %>
+
<%= l(@transfer.outflow_transaction.entry.date, format: :long) %>
Amount
-
<%= format_money -@transfer.amount_money %>
+
<%= format_money -@transfer.amount_abs %>
@@ -47,21 +47,21 @@
-
From
+
To
- <%= render "accounts/logo", account: @transfer.outflow_transaction.account, size: "sm" %> - <%= @transfer.from_name %> + <%= render "accounts/logo", account: @transfer.to_account, size: "sm" %> + <%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
Date
-
<%= l(@transfer.date, format: :long) %>
+
<%= l(@transfer.inflow_transaction.entry.date, format: :long) %>
Amount
-
+<%= format_money @transfer.amount_money %>
+
+<%= format_money @transfer.amount_abs %>
@@ -74,7 +74,6 @@ <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), - value: @transfer.outflow_transaction.notes, rows: 5, "data-auto-submit-form-target": "auto" %> <% end %> @@ -83,25 +82,6 @@ <%= disclosure t(".settings") do %>
- <%= styled_form_with model: @transfer, - class: "p-3", data: { controller: "auto-submit-form" } do |f| %> -
-
-

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

-

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

-
- -
- <%= f.check_box :excluded, - checked: @transfer.inflow_transaction.excluded, - class: "sr-only peer", - "data-auto-submit-form-target": "auto" %> - -
-
- <% end %> -

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

@@ -109,9 +89,9 @@
<%= button_to t(".delete"), - account_transfer_path(@transfer), + transfer_path(@transfer), method: :delete, - class: "rounded-lg px-3 py-2 text-red-500 text-sm + class: "rounded-lg px-3 py-2 whitespace-nowrap text-red-500 text-sm font-medium border border-alpha-black-200", data: { turbo_confirm: true, turbo_frame: "_top" } %>
diff --git a/app/views/transfers/update.turbo_stream.erb b/app/views/transfers/update.turbo_stream.erb new file mode 100644 index 00000000..90d5a7d5 --- /dev/null +++ b/app/views/transfers/update.turbo_stream.erb @@ -0,0 +1,17 @@ +<%= turbo_stream.replace @transfer %> + +<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.inflow_transaction.entry.id}", + partial: "account/transactions/transaction_category", + locals: { entry: @transfer.inflow_transaction.entry } %> + +<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.outflow_transaction.entry.id}", + partial: "account/transactions/transaction_category", + locals: { entry: @transfer.outflow_transaction.entry } %> + +<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.inflow_transaction.entry.id}", + partial: "account/transactions/transfer_match", + locals: { entry: @transfer.inflow_transaction.entry } %> + +<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.outflow_transaction.entry.id}", + partial: "account/transactions/transfer_match", + locals: { entry: @transfer.outflow_transaction.entry } %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index ce280c4d..65697755 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -103,6 +103,30 @@ ], "note": "" }, + { + "warning_type": "Dangerous Eval", + "warning_code": 13, + "fingerprint": "c193307bb82f931950d3bf2855f82f9a7f50d94c5bd950ee2803cb8a8abe5253", + "check_name": "Evaluation", + "message": "Dynamic string evaluated as code", + "file": "app/helpers/styled_form_builder.rb", + "line": 7, + "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/", + "code": "class_eval(\" def #{selector}(method, options = {})\\n merged_options = { class: \\\"form-field__input\\\" }.merge(options)\\n label = build_label(method, options)\\n field = super(method, merged_options)\\n\\n build_styled_field(label, field, merged_options)\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (7 + 1))", + "render_path": null, + "location": { + "type": "method", + "class": "StyledFormBuilder", + "method": null + }, + "user_input": null, + "confidence": "Weak", + "cwe_id": [ + 913, + 95 + ], + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -138,6 +162,5 @@ "note": "" } ], - "updated": "2024-12-18 17:46:13 -0500", - "brakeman_version": "6.2.2" + "brakeman_version": "7.0.0" } diff --git a/config/locales/models/account/transfer/en.yml b/config/locales/models/account/transfer/en.yml deleted file mode 100644 index c0fe38d5..00000000 --- a/config/locales/models/account/transfer/en.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -en: - account/transfer: - from_fallback_name: Originator - name: Transfer from %{from_account} to %{to_account} - to_fallback_name: Receiver - activerecord: - errors: - models: - account/transfer: - attributes: - entries: - must_be_from_different_accounts: must be from different accounts - must_be_marked_as_transfer: must be marked as transfer - must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow - and outflow that net to zero - must_have_exactly_2_entries: must have exactly 2 entries diff --git a/config/locales/models/transfer/en.yml b/config/locales/models/transfer/en.yml new file mode 100644 index 00000000..f373cc8e --- /dev/null +++ b/config/locales/models/transfer/en.yml @@ -0,0 +1,17 @@ +--- +en: + activerecord: + errors: + models: + transfer: + attributes: + base: + must_be_from_different_accounts: Transfer must have different accounts + must_be_within_date_range: Transfer transaction dates must be within + 4 days of each other + must_have_opposite_amounts: Transfer transactions must have opposite + amounts + must_have_single_currency: Transfer must have a single currency + transfer: + name: Transfer to %{to_account} + payment_name: Payment to %{to_account} diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml index af05bcdf..659c5779 100644 --- a/config/locales/views/account/transactions/en.yml +++ b/config/locales/views/account/transactions/en.yml @@ -36,15 +36,8 @@ en: no_transactions: No transactions for this account yet. transaction: transaction transactions: Transactions - mark_transfers: - success: Marked as transfers new: new_transaction: New transaction - selection_bar: - mark_transfers: Mark as transfers? - mark_transfers_confirm: Mark as transfers - mark_transfers_message: By marking transactions as transfers, they will no - longer be included in income or spending calculations. show: account_label: Account amount: Amount @@ -55,9 +48,6 @@ en: balances, and cannot be undone. delete_title: Delete transaction details: Details - exclude_subtitle: This excludes the transaction from any in-app features or - analytics. - exclude_title: Exclude transaction merchant_label: Merchant name_label: Name nature: Type @@ -68,5 +58,6 @@ en: settings: Settings tags_label: Tags uncategorized: "(uncategorized)" - unmark_transfers: - success: Transfer removed + transfer_matches: + create: + success: Transfer created diff --git a/config/locales/views/account/transfers/en.yml b/config/locales/views/account/transfers/en.yml deleted file mode 100644 index 7728d764..00000000 --- a/config/locales/views/account/transfers/en.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -en: - account: - transfers: - create: - success: Transfer created - destroy: - success: Transfer removed - form: - amount: Amount - date: Date - expense: Expense - from: From - income: Income - select_account: Select account - submit: Create transfer - to: To - transfer: Transfer - new: - title: New transfer - show: - delete: Delete - delete_subtitle: This permanently deletes both of the transactions related - to the transfer. This cannot be undone. - delete_title: Delete transfer? - details: Details - exclude_subtitle: This excludes the transfer from any in-app features or analytics. - exclude_title: Exclude transfer - note_label: Notes - note_placeholder: Add a note to this transfer - overview: Overview - settings: Settings - transfer_toggle: - remove_transfer: Remove transfer - remove_transfer_body: This will remove the transfer from this transaction - remove_transfer_confirm: Confirm - update: - success: Transfer updated diff --git a/config/locales/views/categories/en.yml b/config/locales/views/categories/en.yml index 7951e328..1d6aeb6e 100644 --- a/config/locales/views/categories/en.yml +++ b/config/locales/views/categories/en.yml @@ -1,11 +1,8 @@ --- en: - category: - dropdowns: - show: - empty: No categories found - bootstrap: Generate default categories categories: + bootstrap: + success: Default categories created successfully category: delete: Delete category edit: Edit category @@ -18,15 +15,18 @@ en: form: placeholder: Category name index: + bootstrap: Use default categories categories: Categories empty: No categories found new: New category - bootstrap: Use default categories - bootstrap: - success: Default categories created successfully menu: loading: Loading... new: new_category: New category update: success: Category updated successfully + category: + dropdowns: + show: + bootstrap: Generate default categories + empty: No categories found diff --git a/config/locales/views/category/dropdowns/en.yml b/config/locales/views/category/dropdowns/en.yml index 5b9e8248..511e86a9 100644 --- a/config/locales/views/category/dropdowns/en.yml +++ b/config/locales/views/category/dropdowns/en.yml @@ -6,7 +6,6 @@ en: delete: Delete category edit: Edit category show: - add_new: Add new - clear: Clear + clear: Clear category no_categories: No categories found search_placeholder: Search diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml new file mode 100644 index 00000000..669bae2d --- /dev/null +++ b/config/locales/views/transfers/en.yml @@ -0,0 +1,31 @@ +--- +en: + transfers: + create: + success: Transfer created + destroy: + success: Transfer removed + form: + amount: Amount + date: Date + expense: Expense + from: From + income: Income + select_account: Select account + submit: Create transfer + to: To + transfer: Transfer + new: + title: New transfer + show: + delete: Remove transfer + delete_subtitle: This removes the transfer. It will not delete the underlying + transactions. + delete_title: Remove transfer? + details: Details + note_label: Notes + note_placeholder: Add a note to this transfer + overview: Overview + settings: Settings + update: + success: Transfer updated diff --git a/config/routes.rb b/config/routes.rb index 73673ced..440756ba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -46,9 +46,7 @@ Rails.application.routes.draw do resources :merchants, only: %i[index new create edit update destroy] - namespace :account do - resources :transfers, only: %i[new create destroy show update] - end + resources :transfers, only: %i[new create destroy show update] resources :imports, only: %i[index new show create destroy] do post :publish, on: :member @@ -81,6 +79,7 @@ Rails.application.routes.draw do resources :entries, only: :index resources :transactions, only: %i[show new create update destroy] do + resource :transfer_match, only: %i[new create] resource :category, only: :update, controller: :transaction_categories collection do diff --git a/db/migrate/20241231140709_reverse_transfer_relations.rb b/db/migrate/20241231140709_reverse_transfer_relations.rb new file mode 100644 index 00000000..5dbfe795 --- /dev/null +++ b/db/migrate/20241231140709_reverse_transfer_relations.rb @@ -0,0 +1,75 @@ +class ReverseTransferRelations < ActiveRecord::Migration[7.2] + def change + create_table :transfers, id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.references :inflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid + t.references :outflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid + t.string :status, null: false, default: "pending" + t.text :notes + + t.index [ :inflow_transaction_id, :outflow_transaction_id ], unique: true + t.timestamps + end + + reversible do |dir| + dir.up do + execute <<~SQL + INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at) + SELECT + CASE WHEN e1.amount <= 0 THEN e1.entryable_id ELSE e2.entryable_id END as inflow_transaction_id, + CASE WHEN e1.amount <= 0 THEN e2.entryable_id ELSE e1.entryable_id END as outflow_transaction_id, + 'confirmed' as status, + e1.created_at, + e1.updated_at + FROM account_entries e1 + JOIN account_entries e2 ON + e1.transfer_id = e2.transfer_id AND + e1.id != e2.id AND + e1.id < e2.id -- Ensures we don't duplicate transfers from both sides + JOIN accounts a1 ON e1.account_id = a1.id + JOIN accounts a2 ON e2.account_id = a2.id + WHERE + e1.entryable_type = 'Account::Transaction' AND + e2.entryable_type = 'Account::Transaction' AND + e1.transfer_id IS NOT NULL AND + a1.family_id = a2.family_id; + SQL + end + + dir.down do + execute <<~SQL + WITH new_transfers AS ( + INSERT INTO account_transfers (created_at, updated_at) + SELECT created_at, updated_at + FROM transfers + RETURNING id, created_at + ), + transfer_pairs AS ( + SELECT + nt.id as transfer_id, + ae_in.id as inflow_entry_id, + ae_out.id as outflow_entry_id + FROM transfers t + JOIN new_transfers nt ON nt.created_at = t.created_at + JOIN account_entries ae_in ON ae_in.entryable_id = t.inflow_transaction_id + JOIN account_entries ae_out ON ae_out.entryable_id = t.outflow_transaction_id + WHERE + ae_in.entryable_type = 'Account::Transaction' AND + ae_out.entryable_type = 'Account::Transaction' + ) + UPDATE account_entries ae + SET transfer_id = tp.transfer_id + FROM transfer_pairs tp + WHERE ae.id IN (tp.inflow_entry_id, tp.outflow_entry_id); + SQL + end + end + + remove_foreign_key :account_entries, :account_transfers, column: :transfer_id + remove_column :account_entries, :transfer_id, :uuid + remove_column :account_entries, :marked_as_transfer, :boolean + + drop_table :account_transfers, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0bdce796..7c333cc3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do +ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -42,8 +42,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "transfer_id" - t.boolean "marked_as_transfer", default: false, null: false t.uuid "import_id" t.text "notes" t.boolean "excluded", default: false @@ -52,7 +50,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.string "enriched_name" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" - t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" end create_table "account_holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -89,11 +86,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id" end - create_table "account_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -606,6 +598,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do t.index ["family_id"], name: "index_tags_on_family_id" end + create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "inflow_transaction_id", null: false + t.uuid "outflow_transaction_id", null: false + t.string "status", default: "pending", null: false + t.text "notes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true + t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" + t.index ["outflow_transaction_id"], name: "index_transfers_on_outflow_transaction_id" + end + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "first_name" @@ -634,7 +638,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do end add_foreign_key "account_balances", "accounts", on_delete: :cascade - add_foreign_key "account_entries", "account_transfers", column: "transfer_id" add_foreign_key "account_entries", "accounts" add_foreign_key "account_entries", "imports" add_foreign_key "account_holdings", "accounts" @@ -663,5 +666,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do add_foreign_key "sessions", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" + add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id" + add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id" add_foreign_key "users", "families" end diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index cdfd6add..66ab86ed 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -38,7 +38,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, - -> { Account::Transfer.count } => 1 do + -> { Transfer.count } => 1 do post account_trades_url, params: { account_entry: { account_id: @entry.account_id, @@ -59,7 +59,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, - -> { Account::Transfer.count } => 1 do + -> { Transfer.count } => 1 do post account_trades_url, params: { account_entry: { account_id: @entry.account_id, @@ -78,7 +78,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest test "deposit and withdrawal has optional transfer account" do assert_difference -> { Account::Entry.count } => 1, -> { Account::Transaction.count } => 1, - -> { Account::Transfer.count } => 0 do + -> { Transfer.count } => 0 do post account_trades_url, params: { account_entry: { account_id: @entry.account_id, @@ -93,7 +93,6 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest created_entry = Account::Entry.order(created_at: :desc).first assert created_entry.amount.positive? - assert created_entry.marked_as_transfer assert_redirected_to @entry.account end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index d490bfa7..1b077eb2 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -74,7 +74,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest end test "can destroy many transactions at once" do - transactions = @user.family.entries.account_transactions + transactions = @user.family.entries.account_transactions.incomes_and_expenses delete_count = transactions.size assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do diff --git a/test/controllers/account/transfer_matches_controller_test.rb b/test/controllers/account/transfer_matches_controller_test.rb new file mode 100644 index 00000000..0e13883e --- /dev/null +++ b/test/controllers/account/transfer_matches_controller_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Account::TransferMatchesControllerTest < ActionDispatch::IntegrationTest + include Account::EntriesTestHelper + + setup do + sign_in @user = users(:family_admin) + end + + test "matches existing transaction and creates transfer" do + inflow_transaction = create_transaction(amount: 100, account: accounts(:depository)) + outflow_transaction = create_transaction(amount: -100, account: accounts(:investment)) + + assert_difference "Transfer.count", 1 do + post account_transaction_transfer_match_path(inflow_transaction), params: { + transfer_match: { + method: "existing", + matched_entry_id: outflow_transaction.id + } + } + end + + assert_redirected_to transactions_url + assert_equal "Transfer created", flash[:notice] + end + + test "creates transfer for target account" do + inflow_transaction = create_transaction(amount: 100, account: accounts(:depository)) + + assert_difference [ "Transfer.count", "Account::Entry.count", "Account::Transaction.count" ], 1 do + post account_transaction_transfer_match_path(inflow_transaction), params: { + transfer_match: { + method: "new", + target_account_id: accounts(:investment).id + } + } + end + + assert_redirected_to transactions_url + assert_equal "Transfer created", flash[:notice] + end +end diff --git a/test/controllers/account/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb similarity index 55% rename from test/controllers/account/transfers_controller_test.rb rename to test/controllers/transfers_controller_test.rb index 72e14345..391937e8 100644 --- a/test/controllers/account/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -1,19 +1,19 @@ require "test_helper" -class Account::TransfersControllerTest < ActionDispatch::IntegrationTest +class TransfersControllerTest < ActionDispatch::IntegrationTest setup do sign_in users(:family_admin) end test "should get new" do - get new_account_transfer_url + get new_transfer_url assert_response :success end test "can create transfers" do - assert_difference "Account::Transfer.count", 1 do - post account_transfers_url, params: { - account_transfer: { + assert_difference "Transfer.count", 1 do + post transfers_url, params: { + transfer: { from_account_id: accounts(:depository).id, to_account_id: accounts(:credit_card).id, date: Date.current, @@ -26,8 +26,8 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest end test "can destroy transfer" do - assert_difference -> { Account::Transfer.count } => -1, -> { Account::Transaction.count } => -2 do - delete account_transfer_url(account_transfers(:one)) + assert_difference -> { Transfer.count } => -1, -> { Account::Transaction.count } => 0 do + delete transfer_url(transfers(:one)) end end end diff --git a/test/fixtures/account/entries.yml b/test/fixtures/account/entries.yml index 680710be..ccaf29bf 100644 --- a/test/fixtures/account/entries.yml +++ b/test/fixtures/account/entries.yml @@ -31,8 +31,6 @@ transfer_out: amount: 100 currency: USD account: depository - marked_as_transfer: true - transfer: one entryable_type: Account::Transaction entryable: transfer_out @@ -42,7 +40,5 @@ transfer_in: amount: -100 currency: USD account: credit_card - marked_as_transfer: true - transfer: one entryable_type: Account::Transaction entryable: transfer_in diff --git a/test/fixtures/account/transfers.yml b/test/fixtures/account/transfers.yml deleted file mode 100644 index 6aab7788..00000000 --- a/test/fixtures/account/transfers.yml +++ /dev/null @@ -1 +0,0 @@ -one: { } diff --git a/test/fixtures/transfers.yml b/test/fixtures/transfers.yml new file mode 100644 index 00000000..90c1ea94 --- /dev/null +++ b/test/fixtures/transfers.yml @@ -0,0 +1,3 @@ +one: + inflow_transaction: transfer_in + outflow_transaction: transfer_out diff --git a/test/models/account/transfer_test.rb b/test/models/account/transfer_test.rb deleted file mode 100644 index 2c9265c6..00000000 --- a/test/models/account/transfer_test.rb +++ /dev/null @@ -1,69 +0,0 @@ -require "test_helper" - -class Account::TransferTest < ActiveSupport::TestCase - setup do - @outflow = account_entries(:transfer_out) - @inflow = account_entries(:transfer_in) - end - - test "transfer valid if it has inflow and outflow from different accounts for the same amount" do - transfer = Account::Transfer.create! entries: [ @inflow, @outflow ] - - assert transfer.valid? - end - - test "transfer must have 2 transactions" do - invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ] - invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ] - - assert invalid_transfer_1.invalid? - assert invalid_transfer_2.invalid? - end - - test "transfer cannot have 2 transactions from the same account" do - account = accounts(:depository) - - inflow = account.entries.create! \ - date: Date.current, - name: "Inflow", - amount: -100, - currency: "USD", - marked_as_transfer: true, - entryable: Account::Transaction.new - - outflow = account.entries.create! \ - date: Date.current, - name: "Outflow", - amount: 100, - currency: "USD", - marked_as_transfer: true, - entryable: Account::Transaction.new - - assert_raise ActiveRecord::RecordInvalid do - Account::Transfer.create! entries: [ inflow, outflow ] - end - end - - test "all transfer transactions must be marked as transfers" do - @inflow.update! marked_as_transfer: false - - assert_raise ActiveRecord::RecordInvalid do - Account::Transfer.create! entries: [ @inflow, @outflow ] - end - end - - test "single-currency transfer transactions must net to zero" do - @outflow.update! amount: 105 - - assert_raises ActiveRecord::RecordInvalid do - Account::Transfer.create! entries: [ @inflow, @outflow ] - end - end - - test "multi-currency transfer transactions do not have to net to zero" do - @outflow.update! amount: 105, currency: "EUR" - transfer = Account::Transfer.create! entries: [ @inflow, @outflow ] - - assert transfer.valid? - end -end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 74376a7e..33088c81 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -120,11 +120,9 @@ class FamilyTest < ActiveSupport::TestCase test "calculates rolling transaction totals" do account = create_account(balance: 1000, accountable: Depository.new) - liability_account = create_account(balance: 1000, accountable: Loan.new) create_transaction(account: account, date: 2.days.ago.to_date, amount: -500) create_transaction(account: account, date: 1.day.ago.to_date, amount: 100) create_transaction(account: account, date: Date.current, amount: 20) - create_transaction(account: liability_account, date: 2.days.ago.to_date, amount: -333) snapshot = @family.snapshot_transactions diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb new file mode 100644 index 00000000..a4460fd2 --- /dev/null +++ b/test/models/transfer_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class TransferTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @outflow = account_transactions(:transfer_out) + @inflow = account_transactions(:transfer_in) + end + + test "transfer has different accounts, opposing amounts, and within 4 days of each other" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:credit_card), amount: -500) + + assert_difference -> { Transfer.count } => 1 do + Transfer.create!( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + end + end + + test "transfer cannot have 2 transactions from the same account" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: -500) + + transfer = Transfer.new( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + + assert_no_difference -> { Transfer.count } do + transfer.save + end + + assert_equal "Transfer must have different accounts", transfer.errors.full_messages.first + end + + test "Transfer transactions must have opposite amounts" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -400) + + transfer = Transfer.new( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + + assert_no_difference -> { Transfer.count } do + transfer.save + end + + assert_equal "Transfer transactions must have opposite amounts", transfer.errors.full_messages.first + end + + test "transfer dates must be within 4 days of each other" do + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) + inflow_entry = create_transaction(date: 5.days.ago.to_date, account: accounts(:credit_card), amount: -500) + + transfer = Transfer.new( + inflow_transaction: inflow_entry.account_transaction, + outflow_transaction: outflow_entry.account_transaction, + ) + + assert_no_difference -> { Transfer.count } do + transfer.save + end + + assert_equal "Transfer transaction dates must be within 4 days of each other", transfer.errors.full_messages.first + end + + test "from_accounts converts amounts to the to_account's currency" do + accounts(:depository).update!(currency: "EUR") + + eur_account = accounts(:depository).reload + usd_account = accounts(:credit_card) + + ExchangeRate.create!( + from_currency: "EUR", + to_currency: "USD", + rate: 1.1, + date: Date.current, + ) + + transfer = Transfer.from_accounts( + from_account: eur_account, + to_account: usd_account, + date: Date.current, + amount: 500, + ) + + assert_equal 500, transfer.outflow_transaction.entry.amount + assert_equal "EUR", transfer.outflow_transaction.entry.currency + assert_equal -550, transfer.inflow_transaction.entry.amount + assert_equal "USD", transfer.inflow_transaction.entry.currency + + assert_difference -> { Transfer.count } => 1 do + transfer.save! + end + end +end diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 26db48c3..76ab1363 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -210,7 +210,7 @@ class TransactionsTest < ApplicationSystemTestCase end def number_of_transactions_on_page - [ @user.family.entries.without_transfers.count, @page_size ].min + [ @user.family.entries.count, @page_size ].min end def all_transactions_checkbox diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index 601a50f8..a2481b18 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -19,71 +19,13 @@ class TransfersTest < ApplicationSystemTestCase select checking_name, from: "From" select savings_name, from: "To" - fill_in "account_transfer[amount]", with: 500 + fill_in "transfer[amount]", with: 500 fill_in "Date", with: transfer_date click_button "Create transfer" within "#entry-group-" + transfer_date.to_s do - assert_text "Transfer from" + assert_text "Payment to" end end - - test "can match 2 transactions and create a transfer" do - transfer_date = Date.current - outflow = accounts(:depository).entries.create! \ - name: "Outflow from checking account", - date: transfer_date, - amount: 100, - currency: "USD", - entryable: Account::Transaction.new - - inflow = accounts(:credit_card).entries.create! \ - name: "Inflow to cc account", - date: transfer_date, - amount: -100, - currency: "USD", - entryable: Account::Transaction.new - - visit transactions_url - - transaction_entry_checkbox(inflow).check - transaction_entry_checkbox(outflow).check - - bulk_transfer_action_button.click - - click_on "Mark as transfers" - - within "#entry-group-" + transfer_date.to_s do - assert_text "Outflow" - assert_text "Inflow" - end - end - - test "can mark a single transaction as a transfer" do - txn = @user.family.entries.account_transactions.reverse_chronological.first - - within "#" + dom_id(txn) do - assert_text txn.account_transaction.category.name || "Uncategorized" - end - - transaction_entry_checkbox(txn).check - - bulk_transfer_action_button.click - click_on "Mark as transfers" - - within "#" + dom_id(txn) do - assert_no_text "Uncategorized" - end - end - - private - - def transaction_entry_checkbox(transaction_entry) - find("#" + dom_id(transaction_entry, "selection")) - end - - def bulk_transfer_action_button - find("#bulk-transfer-btn") - end end