diff --git a/app/controllers/account/entries_controller.rb b/app/controllers/account/entries_controller.rb new file mode 100644 index 00000000..02896ebb --- /dev/null +++ b/app/controllers/account/entries_controller.rb @@ -0,0 +1,91 @@ +class Account::EntriesController < ApplicationController + layout "with_sidebar" + + before_action :set_account + before_action :set_entry, only: %i[ edit update show destroy ] + + def transactions + @transaction_entries = @account.entries.account_transactions.reverse_chronological + end + + def valuations + @valuation_entries = @account.entries.account_valuations.reverse_chronological + end + + def new + @entry = @account.entries.build.tap do |entry| + if params[:entryable_type] + entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new + else + entry.entryable = Account::Valuation.new + end + end + end + + def create + @entry = @account.entries.build(entry_params_with_defaults(entry_params)) + + if @entry.save + @entry.sync_account_later + redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first) + 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[:error] = @entry.errors.full_messages.to_sentence + redirect_to account_path(@account) + end + end + + def edit + end + + def update + @entry.update! entry_params + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } + format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) } + end + end + + def show + end + + def destroy + @entry.destroy! + @entry.sync_account_later + redirect_back_or_to account_url(@entry.account), notice: t(".success") + end + + private + + def set_account + @account = Current.family.accounts.find(params[:account_id]) + end + + def set_entry + @entry = @account.entries.find(params[:id]) + end + + def permitted_entryable_attributes + entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type] + + case entryable_type + when "Account::Transaction" + [ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ] + else + [ :id ] + end + end + + def entry_params + params.require(:account_entry) + .permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes) + end + + # entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug) + def entry_params_with_defaults(params) + params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {}) + end +end diff --git a/app/controllers/account/transaction/rows_controller.rb b/app/controllers/account/transaction/rows_controller.rb deleted file mode 100644 index 9bc823f3..00000000 --- a/app/controllers/account/transaction/rows_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -class Account::Transaction::RowsController < ApplicationController - before_action :set_transaction, only: %i[ show update ] - - def show - end - - def update - @transaction.update! transaction_params - - redirect_to account_transaction_row_path(@transaction.account, @transaction) - end - - private - - def transaction_params - params.require(:transaction).permit(:category_id) - end - - def set_transaction - @transaction = Current.family.accounts.find(params[:account_id]).transactions.find(params[:transaction_id]) - end -end diff --git a/app/controllers/account/transaction/rules_controller.rb b/app/controllers/account/transaction/rules_controller.rb deleted file mode 100644 index 704b2119..00000000 --- a/app/controllers/account/transaction/rules_controller.rb +++ /dev/null @@ -1,6 +0,0 @@ -class Account::Transaction::RulesController < ApplicationController - layout "with_sidebar" - - def index - end -end diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb deleted file mode 100644 index a4c11646..00000000 --- a/app/controllers/account/transactions_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -class Account::TransactionsController < ApplicationController - layout "with_sidebar" - - before_action :set_account - before_action :set_transaction, only: %i[ show update destroy ] - - def index - @transactions = @account.transactions.ordered - end - - def show - end - - def update - @transaction.update! transaction_params - @transaction.sync_account_later - - respond_to do |format| - format.html { redirect_back_or_to account_transaction_path(@account, @transaction), notice: t(".success") } - format.turbo_stream { render turbo_stream: turbo_stream.replace(@transaction) } - end - end - - def destroy - @transaction.destroy! - @transaction.sync_account_later - redirect_back_or_to account_url(@transaction.account), notice: t(".success") - end - - private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - - def set_transaction - @transaction = @account.transactions.find(params[:id]) - end - - def search_params - params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: []) - end - - def transaction_params - params.require(:account_transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: []) - end -end diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb deleted file mode 100644 index bdaba126..00000000 --- a/app/controllers/account/valuations_controller.rb +++ /dev/null @@ -1,61 +0,0 @@ -class Account::ValuationsController < ApplicationController - before_action :set_account - before_action :set_valuation, only: %i[ show edit update destroy ] - - def new - @valuation = @account.valuations.new - end - - def show - end - - def create - @valuation = @account.valuations.build(valuation_params) - - if @valuation.save - @valuation.sync_account_later - redirect_to account_path(@account), notice: "Valuation created" - 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[:error] = @valuation.errors.full_messages.to_sentence - redirect_to account_path(@account) - end - end - - def edit - end - - def update - if @valuation.update(valuation_params) - @valuation.sync_account_later - redirect_to account_path(@account), 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[:error] = @valuation.errors.full_messages.to_sentence - redirect_to account_path(@account) - end - end - - def destroy - @valuation.destroy! - @valuation.sync_account_later - - redirect_to account_path(@account), notice: t(".success") - end - - private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - - def set_valuation - @valuation = @account.valuations.find(params[:id]) - end - - def valuation_params - params.require(:account_valuation).permit(:date, :value, :currency) - end -end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 42cdd1f2..92904be5 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -21,7 +21,7 @@ class PagesController < ApplicationController @accounts = Current.family.accounts @account_groups = @accounts.by_group(period: @period, currency: Current.family.currency) - @transactions = Current.family.transactions.limit(6).order(date: :desc) + @transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological # TODO: Placeholders for trendlines placeholder_series_data = 10.times.map do |i| diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index ed38b535..5ad98082 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -3,8 +3,8 @@ class TransactionsController < ApplicationController def index @q = search_params - result = Current.family.transactions.search(@q).ordered - @pagy, @transactions = pagy(result, items: params[:per_page] || "10") + result = Current.family.entries.account_transactions.search(@q).reverse_chronological + @pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50") @totals = { count: result.select { |t| t.currency == Current.family.currency }.count, @@ -14,25 +14,26 @@ class TransactionsController < ApplicationController end def new - @transaction = Account::Transaction.new.tap do |txn| + @entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e| if params[:account_id] - txn.account = Current.family.accounts.find(params[:account_id]) + e.account = Current.family.accounts.find(params[:account_id]) end end end def create - @transaction = Current.family.accounts - .find(params[:transaction][:account_id]) - .transactions - .create!(transaction_params.merge(amount: amount)) + @entry = Current.family + .accounts + .find(params[:account_entry][:account_id]) + .entries + .create!(transaction_entry_params.merge(amount: amount)) - @transaction.sync_account_later - redirect_back_or_to account_path(@transaction.account), notice: t(".success") + @entry.sync_account_later + redirect_back_or_to account_path(@entry.account), notice: t(".success") end def bulk_delete - destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids]) + destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count) end @@ -40,19 +41,18 @@ class TransactionsController < ApplicationController end def bulk_update - transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids]) - if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!) - redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count) - else - flash.now[:error] = t(".failure") - render :index, status: :unprocessable_entity - end + updated = Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .bulk_update!(bulk_update_params) + + redirect_back_or_to transactions_url, notice: t(".success", count: updated) end def mark_transfers Current.family - .transactions - .where(id: bulk_update_params[:transaction_ids]) + .entries + .where(id: bulk_update_params[:entry_ids]) .mark_transfers! redirect_back_or_to transactions_url, notice: t(".success") @@ -60,40 +60,45 @@ class TransactionsController < ApplicationController def unmark_transfers Current.family - .transactions - .where(id: bulk_update_params[:transaction_ids]) + .entries + .where(id: bulk_update_params[:entry_ids]) .update_all marked_as_transfer: false redirect_back_or_to transactions_url, notice: t(".success") end + def rules + end + private def amount if nature.income? - transaction_params[:amount].to_d * -1 + transaction_entry_params[:amount].to_d * -1 else - transaction_params[:amount].to_d + transaction_entry_params[:amount].to_d end end def nature - params[:transaction][:nature].to_s.inquiry + params[:account_entry][:nature].to_s.inquiry end def bulk_delete_params - params.require(:bulk_delete).permit(transaction_ids: []) + params.require(:bulk_delete).permit(entry_ids: []) end def bulk_update_params - params.require(:bulk_update).permit(:date, :notes, :excluded, :category_id, :merchant_id, transaction_ids: []) + params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: []) end def search_params params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: []) end - def transaction_params - params.require(:transaction).permit(:name, :date, :amount, :currency, :category_id, tag_ids: []) + def transaction_entry_params + params.require(:account_entry) + .permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ]) + .with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {}) end end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb new file mode 100644 index 00000000..17634b89 --- /dev/null +++ b/app/helpers/account/entries_helper.rb @@ -0,0 +1,39 @@ +module Account::EntriesHelper + def permitted_entryable_partial_path(entry, relative_partial_path) + "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 + end + + def entry_icon(entry, is_oldest: false) + if is_oldest + "keyboard" + elsif entry.trend.direction.up? + "arrow-up" + elsif entry.trend.direction.down? + "arrow-down" + else + "minus" + end + end + + def entry_style(entry, is_oldest: false) + color = is_oldest ? "#D444F1" : entry.trend.color + + mixed_hex_styles(color) + end + + private + + def permitted_entryable_key(entry) + permitted_entryable_paths = %w[transaction valuation] + entry.entryable_name_short.presence_in(permitted_entryable_paths) + end +end diff --git a/app/helpers/account/transactions_helper.rb b/app/helpers/account/transactions_helper.rb deleted file mode 100644 index bc888360..00000000 --- a/app/helpers/account/transactions_helper.rb +++ /dev/null @@ -1,24 +0,0 @@ -module Account::TransactionsHelper - def unconfirmed_transfer?(transaction) - transaction.marked_as_transfer && transaction.transfer.nil? - end - - def group_transactions_by_date(transactions) - grouped_by_date = {} - - transactions.each do |transaction| - if transaction.transfer - transfer_date = transaction.transfer.inflow_transaction.date - grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] } - unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer) - grouped_by_date[transfer_date][:transfers] << transaction.transfer - end - else - grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] } - grouped_by_date[transaction.date][:transactions] << transaction - end - end - - grouped_by_date - end -end diff --git a/app/helpers/account/valuations_helper.rb b/app/helpers/account/valuations_helper.rb deleted file mode 100644 index 5143e43d..00000000 --- a/app/helpers/account/valuations_helper.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Account::ValuationsHelper - def valuation_icon(valuation) - if valuation.oldest? - "keyboard" - elsif valuation.trend.direction.up? - "arrow-up" - elsif valuation.trend.direction.down? - "arrow-down" - else - "minus" - end - end - - def valuation_style(valuation) - color = valuation.oldest? ? "#D444F1" : valuation.trend.color - - <<-STYLE.strip - background-color: color-mix(in srgb, #{color} 5%, white); - border-color: color-mix(in srgb, #{color} 10%, white); - color: #{color}; - STYLE - end -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a5e3af94..bb9462a5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -65,6 +65,20 @@ module ApplicationHelper end end + def mixed_hex_styles(hex) + color = hex || "#1570EF" # blue-600 + + <<-STYLE.strip + background-color: color-mix(in srgb, #{color} 5%, white); + border-color: color-mix(in srgb, #{color} 10%, white); + color: #{color}; + STYLE + end + + def circle_logo(name, hex: nil, size: "md") + render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size } + end + def return_to_path(params, fallback = root_path) uri = URI.parse(params[:return_to] || fallback) uri.relative? ? uri.path : root_path @@ -123,7 +137,7 @@ module ApplicationHelper ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] }) end - def totals_by_currency(collection:, money_method:, separator: " | ", negate: false, options: {}) + def totals_by_currency(collection:, money_method:, separator: " | ", negate: false) collection.group_by(&:currency) .transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) } .map { |_currency, money| format_money(money) } diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 65959f0e..86968e02 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -11,6 +11,22 @@ module FormsHelper end end + def selectable_categories + Current.family.categories.alphabetically + end + + def selectable_merchants + Current.family.merchants.alphabetically + end + + def selectable_accounts + Current.family.accounts.alphabetically + end + + def selectable_tags + Current.family.tags.alphabetically.pluck(:name, :id) + end + private def radio_tab_contents(label:, icon:) tag.div(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 diff --git a/app/helpers/account/transaction/searches_helper.rb b/app/helpers/transactions_helper.rb similarity index 96% rename from app/helpers/account/transaction/searches_helper.rb rename to app/helpers/transactions_helper.rb index 09d3e0dc..eab4bce1 100644 --- a/app/helpers/account/transaction/searches_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -1,4 +1,4 @@ -module Account::Transaction::SearchesHelper +module TransactionsHelper def transaction_search_filters [ { key: "account_filter", name: "Account", icon: "layers" }, diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index 02db8bbe..ed989aee 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -25,7 +25,7 @@ export default class extends Controller { submitBulkRequest(e) { const form = e.target.closest("form"); const scope = e.params.scope - this.#addHiddenFormInputsForSelectedIds(form, `${scope}[transaction_ids][]`, this.selectedIdsValue) + this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue) form.requestSubmit() } diff --git a/app/models/account.rb b/app/models/account.rb index 91b7532a..b7b11692 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -8,14 +8,17 @@ class Account < ApplicationRecord belongs_to :family belongs_to :institution, optional: true + + has_many :entries, dependent: :destroy, class_name: "Account::Entry" + has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction" + has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" has_many :balances, dependent: :destroy - has_many :valuations, dependent: :destroy - has_many :transactions, dependent: :destroy has_many :imports, dependent: :destroy monetize :balance enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true + enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } scope :active, -> { where(is_active: true) } scope :assets, -> { where(classification: "asset") } @@ -35,8 +38,7 @@ class Account < ApplicationRecord # e.g. Wise, Revolut accounts that have transactions in multiple currencies def multi_currency? - currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq - currencies.count > 1 + entries.select(:currency).distinct.count > 1 end # e.g. Accounts denominated in currency other than family currency @@ -44,16 +46,6 @@ class Account < ApplicationRecord currency != family.currency end - def self.by_provider - # TODO: When 3rd party providers are supported, dynamically load all providers and their accounts - [ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ] - end - - def self.some_syncing? - exists?(status: "syncing") - end - - def series(period: Period.all, currency: self.currency) balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code) @@ -93,11 +85,19 @@ class Account < ApplicationRecord account.accountable = Accountable.from_type(attributes[:accountable_type])&.new # Always build the initial valuation - account.valuations.build(date: Date.current, value: attributes[:balance], currency: account.currency) + account.entries.build \ + date: Date.current, + amount: attributes[:balance], + currency: account.currency, + entryable: Account::Valuation.new # Conditionally build the optional start valuation if start_date.present? && start_balance.present? - account.valuations.build(date: start_date, value: start_balance, currency: account.currency) + account.entries.build \ + date: start_date, + amount: start_balance, + currency: account.currency, + entryable: Account::Valuation.new end account.save! diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb index 24bd451a..223418e4 100644 --- a/app/models/account/balance/calculator.rb +++ b/app/models/account/balance/calculator.rb @@ -1,123 +1,115 @@ class Account::Balance::Calculator - attr_reader :daily_balances, :errors, :warnings + attr_reader :errors, :warnings - def initialize(account, options = {}) - @daily_balances = [] - @errors = [] - @warnings = [] - @account = account - @calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max + def initialize(account, options = {}) + @errors = [] + @warnings = [] + @account = account + @calc_start_date = calculate_sync_start(options[:calc_start_date]) + end + + def daily_balances + @daily_balances ||= calculate_daily_balances + end + + private + + attr_reader :calc_start_date, :account + + def calculate_sync_start(provided_start_date = nil) + if account.balances.any? + [ provided_start_date, account.effective_start_date ].compact.max + else + account.effective_start_date + end end - def calculate - prior_balance = implied_start_balance + def calculate_daily_balances + prior_balance = nil - calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date| - valuation = normalized_valuations.find { |v| v["date"] == date } + calculated_balances = (calc_start_date..Date.current).map do |date| + valuation_entry = find_valuation_entry(date) - if valuation - current_balance = valuation["value"] + if valuation_entry + current_balance = valuation_entry.amount + elsif prior_balance.nil? + current_balance = implied_start_balance else - txn_flows = transaction_flows(date) + txn_entries = syncable_transaction_entries.select { |e| e.date == date } + txn_flows = transaction_flows(txn_entries) current_balance = prior_balance - txn_flows end prior_balance = current_balance - { date:, balance: current_balance, currency: @account.currency, updated_at: Time.current } + { date:, balance: current_balance, currency: account.currency, updated_at: Time.current } end - @daily_balances = [ - { date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current }, - *calculated_balances - ] - - if @account.foreign_currency? - converted_balances = convert_balances_to_family_currency - @daily_balances.concat(converted_balances) + if account.foreign_currency? + calculated_balances.concat(convert_balances_to_family_currency(calculated_balances)) end - self + calculated_balances end - private - def convert_balances_to_family_currency - rates = ExchangeRate.get_rates( - @account.currency, - @account.family.currency, - @calc_start_date..Date.current - ).to_a + def syncable_entries + @entries ||= account.entries.where("date >= ?", calc_start_date).to_a + end - # Abort conversion if some required rates are missing - if rates.length != @daily_balances.length - @errors << :sync_message_missing_rates - return [] - end + def syncable_transaction_entries + @syncable_transaction_entries ||= syncable_entries.select { |e| e.account_transaction? } + end - @daily_balances.map.with_index do |balance, index| - converted_balance = balance[:balance] * rates[index].rate - { date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current } - end + def find_valuation_entry(date) + syncable_entries.find { |entry| entry.date == date && entry.account_valuation? } + end + + def transaction_flows(transaction_entries) + converted_entries = transaction_entries.map { |entry| convert_entry_to_account_currency(entry) }.compact + flows = converted_entries.sum(&:amount) + flows *= -1 if account.liability? + flows + end + + def convert_balances_to_family_currency(balances) + rates = ExchangeRate.get_rates( + account.currency, + account.family.currency, + calc_start_date..Date.current + ).to_a + + # Abort conversion if some required rates are missing + if rates.length != balances.length + @errors << :sync_message_missing_rates + return [] end - # For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency) - def normalize_entries_to_account_currency(entries, value_key) - grouped_entries = entries.group_by(&:currency) - normalized_entries = [] + balances.map.with_index do |balance, index| + converted_balance = balance[:balance] * rates[index].rate + { date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current } + end + end - grouped_entries.each do |currency, entries| - if currency != @account.currency - dates = entries.map(&:date).uniq - rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a - if rates.length != dates.length - @errors << :sync_message_missing_rates - else - entries.each do |entry| - ## There can be several entries on the same date so we cannot rely on indeces - rate = rates.find { |rate| rate.date == entry.date } - value = entry.send(value_key) - value *= rate.rate - normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency) - end - end - else - normalized_entries.concat(entries) - end - end + # Multi-currency accounts have transactions in many currencies + def convert_entry_to_account_currency(entry) + return entry if entry.currency == account.currency - normalized_entries + converted_entry = entry.dup + + rate = ExchangeRate.find_rate(from: entry.currency, to: account.currency, date: entry.date) + + unless rate + @errors << :sync_message_missing_rates + return nil end - def normalized_valuations - @normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value) - end + converted_entry.currency = account.currency + converted_entry.amount = entry.amount * rate.rate + converted_entry + end - def normalized_transactions - @normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount) - end - - def transaction_flows(date) - flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] } - flows *= -1 if @account.classification == "liability" - flows - end - - def implied_start_balance - if @calc_start_date > @account.effective_start_date - return @account.balance_on(@calc_start_date) - end - - oldest_valuation_date = normalized_valuations.first&.date - oldest_transaction_date = normalized_transactions.first&.date - oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min - - if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date - oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date } - oldest_valuation["value"].to_d - else - net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d } - net_transaction_flows *= -1 if @account.classification == "liability" - @account.balance.to_d + net_transaction_flows - end - end + def implied_start_balance + transaction_entries = syncable_transaction_entries.select { |e| e.date > calc_start_date } + account.balance.to_d + transaction_flows(transaction_entries) + end end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb new file mode 100644 index 00000000..0b14ee6f --- /dev/null +++ b/app/models/account/entry.rb @@ -0,0 +1,196 @@ +class Account::Entry < ApplicationRecord + include Monetizable + + monetize :amount + + belongs_to :account + belongs_to :transfer, optional: true + + delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy + accepts_nested_attributes_for :entryable + + validates :date, :amount, :currency, presence: true + validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } + + scope :chronological, -> { order(:date, :created_at) } + scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) } + scope :without_transfers, -> { where(marked_as_transfer: false) } + 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 + select( + "account_entries.*", + "account_entries.amount * COALESCE(er.rate, 1) AS converted_amount" + ) + .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ])) + .where("er.rate IS NOT NULL OR account_entries.currency = ?", currency) + } + + def sync_account_later + if destroyed? + sync_start_date = previous_entry&.date + else + sync_start_date = [ date_previously_was, date ].compact.min + end + + account.sync_later(sync_start_date) + end + + def inflow? + amount <= 0 && account_transaction? + end + + def outflow? + amount > 0 && account_transaction? + end + + def first_of_type? + first_entry = account + .entries + .where("entryable_type = ?", entryable_type) + .order(:date) + .first + + first_entry&.id == id + end + + def entryable_name_short + entryable_type.demodulize.underscore + end + + def trend + @trend ||= create_trend + end + + class << self + def daily_totals(entries, currency, period: Period.last_30_days) + # Sum spending and income for each day in the period with the given currency + select( + "gs.date", + "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending", + "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income" + ) + .from(entries.with_converted_amount(currency), :e) + .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ])) + .group("gs.date") + end + + def daily_rolling_totals(entries, currency, period: Period.last_30_days) + # Extend the period to include the rolling window + period_with_rolling = period.extend_backward(period.date_range.count.days) + + # Aggregate the rolling sum of spending and income based on daily totals + rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling)) + .select( + "*", + sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]), + sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ]) + ) + .order(:date) + + # Trim the results to the original period + select("*").from(rolling_totals).where("date >= ?", period.date_range.first) + end + + def 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], + entryable_attributes: { + notes: bulk_update_params[:notes], + category_id: bulk_update_params[:category_id], + merchant_id: bulk_update_params[:merchant_id] + }.compact_blank + }.compact_blank + + return 0 if bulk_attributes.blank? + + transaction do + all.each do |entry| + bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present? + entry.update! bulk_attributes + end + end + + all.size + end + + def income_total(currency = "USD") + account_transactions.includes(:entryable) + .where("account_entries.amount <= 0") + .where("account_entries.currency = ?", currency) + .reject { |e| e.marked_as_transfer? } + .sum(&:amount_money) + end + + def expense_total(currency = "USD") + account_transactions.includes(:entryable) + .where("account_entries.amount > 0") + .where("account_entries.currency = ?", currency) + .reject { |e| e.marked_as_transfer? } + .sum(&:amount_money) + end + + def search(params) + query = all + query = query.where("account_entries.name ILIKE ?", "%#{params[:search]}%") if params[:search].present? + query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present? + query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present? + + if params[:accounts].present? || params[:account_ids].present? + query = query.joins(:account) + end + + query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present? + query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present? + + # Search attributes on each entryable to further refine results + entryable_ids = entryable_search(params) + query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil? + + query + 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 + + private + + def previous_entry + @previous_entry ||= account + .entries + .where("date < ?", date) + .where("entryable_type = ?", entryable_type) + .order(date: :desc) + .first + end + + def create_trend + TimeSeries::Trend.new \ + current: amount_money, + previous: previous_entry&.amount_money, + favorable_direction: account.favorable_direction + end +end diff --git a/app/models/account/entryable.rb b/app/models/account/entryable.rb new file mode 100644 index 00000000..5a23bd81 --- /dev/null +++ b/app/models/account/entryable.rb @@ -0,0 +1,13 @@ +module Account::Entryable + extend ActiveSupport::Concern + + TYPES = %w[ Account::Valuation Account::Transaction ] + + def self.from_type(entryable_type) + entryable_type.presence_in(TYPES).constantize + end + + included do + has_one :entry, as: :entryable, touch: true + end +end diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb index 1b47caeb..a3e09ef9 100644 --- a/app/models/account/syncable.rb +++ b/app/models/account/syncable.rb @@ -8,17 +8,22 @@ module Account::Syncable def sync(start_date = nil) update!(status: "syncing") - sync_exchange_rates + if multi_currency? || foreign_currency? + sync_exchange_rates + end - calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present? + calculator = Account::Balance::Calculator.new(self, { calc_start_date: start_date }) - calculator = Account::Balance::Calculator.new(self, { calc_start_date: }) - calculator.calculate self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique) self.balances.where("date < ?", effective_start_date).delete_all new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance] - update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings) + update! \ + status: "ok", + last_sync_date: Date.current, + balance: new_balance, + sync_errors: calculator.errors, + sync_warnings: calculator.warnings rescue => e update!(status: "error", sync_errors: [ :sync_message_unknown_error ]) logger.error("Failed to sync account #{id}: #{e.message}") @@ -37,10 +42,7 @@ module Account::Syncable # The earliest date we can calculate a balance for def effective_start_date - first_valuation_date = self.valuations.order(:date).pluck(:date).first - first_transaction_date = self.transactions.order(:date).pluck(:date).first - - [ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current + @effective_start_date ||= entries.order(:date).first.try(:date) || Date.current end # Finds all the rate pairs that are required to calculate balances for an account and syncs them @@ -48,7 +50,7 @@ module Account::Syncable rate_candidates = [] if multi_currency? - transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq + transactions_in_foreign_currency = self.entries.where.not(currency: self.currency).pluck(:currency, :date).uniq transactions_in_foreign_currency.each do |currency, date| rate_candidates << { date: date, from_currency: currency, to_currency: self.currency } end @@ -60,6 +62,8 @@ module Account::Syncable end end + return if rate_candidates.blank? + existing_rates = ExchangeRate.where( base_currency: rate_candidates.map { |rc| rc[:from_currency] }, converted_currency: rate_candidates.map { |rc| rc[:to_currency] }, diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index df4b4621..bda2a2f4 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -1,118 +1,32 @@ class Account::Transaction < ApplicationRecord - include Monetizable + include Account::Entryable - monetize :amount - - belongs_to :account - belongs_to :transfer, optional: true, class_name: "Account::Transfer" belongs_to :category, optional: true belongs_to :merchant, optional: true has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings + accepts_nested_attributes_for :taggings, allow_destroy: true - validates :name, :date, :amount, :account, presence: true - - scope :ordered, -> { order(date: :desc) } scope :active, -> { where(excluded: false) } - scope :inflows, -> { where("amount <= 0") } - scope :outflows, -> { where("amount > 0") } - scope :by_name, ->(name) { where("account_transactions.name ILIKE ?", "%#{name}%") } - scope :with_categories, ->(categories) { joins(:category).where(categories: { name: categories }) } - scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) } - scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) } - scope :with_merchants, ->(merchants) { joins(:merchant).where(merchants: { name: merchants }) } - scope :on_or_after_date, ->(date) { where("account_transactions.date >= ?", date) } - scope :on_or_before_date, ->(date) { where("account_transactions.date <= ?", date) } - scope :with_converted_amount, ->(currency = Current.family.currency) { - # Join with exchange rates to convert the amount to the given currency - # If no rate is available, exclude the transaction from the results - select( - "account_transactions.*", - "account_transactions.amount * COALESCE(er.rate, 1) AS converted_amount" - ) - .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_transactions.date = er.date AND account_transactions.currency = er.base_currency AND er.converted_currency = ?", currency ])) - .where("er.rate IS NOT NULL OR account_transactions.currency = ?", currency) - } - - def inflow? - amount <= 0 - end - - def outflow? - amount > 0 - end - - def transfer? - marked_as_transfer - end - - def sync_account_later - if destroyed? - sync_start_date = previous_transaction_date - else - sync_start_date = [ date_previously_was, date ].compact.min - end - - account.sync_later(sync_start_date) - end class << self - def income_total(currency = "USD") - inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money) - end - - def expense_total(currency = "USD") - outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money) - 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(transactions: all).save if all.count == 2 - end - - def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency) - # Sum spending and income for each day in the period with the given currency - select( - "gs.date", - "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending", - "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income" - ) - .from(transactions.with_converted_amount(currency).where(marked_as_transfer: false), :t) - .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ])) - .group("gs.date") - end - - def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency) - # Extend the period to include the rolling window - period_with_rolling = period.extend_backward(period.date_range.count.days) - - # Aggregate the rolling sum of spending and income based on daily totals - rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency)) - .select( - "*", - sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]), - sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ]) - ) - .order("date") - - # Trim the results to the original period - select("*").from(rolling_totals).where("date >= ?", period.date_range.first) - end - def search(params) - query = all.includes(:transfer) - query = query.by_name(params[:search]) if params[:search].present? - query = query.with_categories(params[:categories]) if params[:categories].present? - query = query.with_accounts(params[:accounts]) if params[:accounts].present? - query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present? - query = query.with_merchants(params[:merchants]) if params[:merchants].present? - query = query.on_or_after_date(params[:start_date]) if params[:start_date].present? - query = query.on_or_before_date(params[:end_date]) if params[:end_date].present? + query = all + query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present? + query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present? query end + + def requires_search?(params) + searchable_keys.any? { |key| params.key?(key) } + end + + private + + def searchable_keys + %i[ categories merchants ] + end end private diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb index 83ef7e00..975e0440 100644 --- a/app/models/account/transfer.rb +++ b/app/models/account/transfer.rb @@ -1,21 +1,42 @@ class Account::Transfer < ApplicationRecord - has_many :transactions, dependent: :nullify + has_many :entries, dependent: :nullify 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 + end + + def from_name + outflow_transaction&.account&.name + end + + def to_name + inflow_transaction&.account&.name + end + + def name + return nil unless from_name && to_name + I18n.t("account.transfer.name", from_account: from_name, to_account: to_name) + end + def inflow_transaction - transactions.find { |t| t.inflow? } + entries.find { |e| e.inflow? } end def outflow_transaction - transactions.find { |t| t.outflow? } + entries.find { |e| e.outflow? } end def destroy_and_remove_marks! transaction do - transactions.each do |t| - t.update! marked_as_transfer: false + entries.each do |e| + e.update! marked_as_transfer: false end destroy! @@ -24,39 +45,52 @@ class Account::Transfer < ApplicationRecord class << self def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:) - outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true) - inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true) + outflow = from_account.entries.build \ + amount: amount.abs, + currency: currency, + date: date, + name: name, + marked_as_transfer: true, + entryable: Account::Transaction.new - new transactions: [ outflow, inflow ] + inflow = to_account.entries.build \ + amount: amount.abs * -1, + currency: currency, + date: date, + name: name, + marked_as_transfer: true, + entryable: Account::Transaction.new + + new entries: [ outflow, inflow ] end end private def single_currency_transfer? - transactions.map(&:currency).uniq.size == 1 + entries.map { |e| e.currency }.uniq.size == 1 end def transaction_count - unless transactions.size == 2 - errors.add :transactions, "must have exactly 2 transactions" + unless entries.size == 2 + errors.add :entries, "must have exactly 2 entries" end end def from_different_accounts - accounts = transactions.map(&:account_id).uniq - errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size + 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 transactions.sum(&:amount).zero? + unless entries.sum(&:amount).zero? errors.add :transactions, "must have an inflow and outflow that net to zero" end end def all_transactions_marked - unless transactions.all?(&:marked_as_transfer) - errors.add :transactions, "must be marked as transfer" + unless entries.all?(&:marked_as_transfer) + errors.add :entries, "must be marked as transfer" end end end diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index ffe21abe..93ebf5ff 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -1,48 +1,13 @@ class Account::Valuation < ApplicationRecord - include Monetizable + include Account::Entryable - monetize :value - - belongs_to :account - - validates :account, :date, :value, presence: true - validates :date, uniqueness: { scope: :account_id } - - scope :chronological, -> { order(:date) } - scope :reverse_chronological, -> { order(date: :desc) } - - def trend - @trend ||= create_trend - end - - def oldest? - account.valuations.chronological.limit(1).pluck(:date).first == self.date - end - - def sync_account_later - if destroyed? - sync_start_date = previous_valuation&.date - else - sync_start_date = [ date_previously_was, date ].compact.min + class << self + def search(_params) + all end - account.sync_later(sync_start_date) + def requires_search?(_params) + false + end end - - private - - def previous_valuation - @previous_valuation ||= self.account - .valuations - .where("date < ?", date) - .order(date: :desc) - .first - end - - def create_trend - TimeSeries::Trend.new \ - current: self.value, - previous: previous_valuation&.value, - favorable_direction: account.favorable_direction - end end diff --git a/app/models/concerns/monetizable.rb b/app/models/concerns/monetizable.rb index 909c7210..3b363147 100644 --- a/app/models/concerns/monetizable.rb +++ b/app/models/concerns/monetizable.rb @@ -6,7 +6,7 @@ module Monetizable fields.each do |field| define_method("#{field}_money") do value = self.send(field) - value.nil? ? nil : Money.new(value, currency) + value.nil? ? nil : Money.new(value, currency || Money.default_currency) end end end diff --git a/app/models/family.rb b/app/models/family.rb index 378dd76e..5e81e5f4 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -3,7 +3,8 @@ class Family < ApplicationRecord has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy has_many :institutions, dependent: :destroy - has_many :transactions, through: :accounts, class_name: "Account::Transaction" + has_many :transactions, through: :accounts + has_many :entries, through: :accounts has_many :imports, through: :accounts has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy @@ -34,17 +35,18 @@ class Family < ApplicationRecord def snapshot_account_transactions period = Period.last_30_days - results = accounts.active.joins(:transactions) - .select( - "accounts.*", - "COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending", - "COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income" - ) - .where("account_transactions.date >= ?", period.date_range.begin) - .where("account_transactions.date <= ?", period.date_range.end) - .where("account_transactions.marked_as_transfer = ?", false) - .group("id") - .to_a + results = accounts.active.joins(:entries) + .select( + "accounts.*", + "COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending", + "COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income" + ) + .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") + .group("id") + .to_a results.each do |r| r.define_singleton_method(:savings_rate) do @@ -60,7 +62,8 @@ class Family < ApplicationRecord end def snapshot_transactions - rolling_totals = Account::Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency) + candidate_entries = entries.account_transactions.without_transfers + rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days) spending = [] income = [] @@ -89,10 +92,6 @@ class Family < ApplicationRecord } end - def effective_start_date - accounts.active.joins(:balances).minimum("account_balances.date") || Date.current - end - def net_worth assets - liabilities end diff --git a/app/models/import.rb b/app/models/import.rb index ef4e3675..6a02bf22 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -111,7 +111,7 @@ class Import < ApplicationRecord end def generate_transactions - transactions = [] + transaction_entries = [] category_cache = {} tag_cache = {} @@ -126,18 +126,17 @@ class Import < ApplicationRecord category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present? - txn = account.transactions.build \ + entry = account.entries.build \ name: row["name"].presence || FALLBACK_TRANSACTION_NAME, date: Date.iso8601(row["date"]), - category: category, - tags: tags, - amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation - currency: account.currency + currency: account.currency, + amount: BigDecimal(row["amount"]) * -1, + entryable: Account::Transaction.new(category: category, tags: tags) - transactions << txn + transaction_entries << entry end - transactions + transaction_entries end def create_expected_fields diff --git a/app/views/account/transactions/_empty.html.erb b/app/views/account/entries/_empty.html.erb similarity index 100% rename from app/views/account/transactions/_empty.html.erb rename to app/views/account/entries/_empty.html.erb diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb new file mode 100644 index 00000000..47965508 --- /dev/null +++ b/app/views/account/entries/_entry.html.erb @@ -0,0 +1,4 @@ +<%# locals: (entry:, **opts) %> +<%= turbo_frame_tag dom_id(entry) do %> + <%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %> +<% end %> diff --git a/app/views/account/entries/_entry_group.html.erb b/app/views/account/entries/_entry_group.html.erb new file mode 100644 index 00000000..c47d7130 --- /dev/null +++ b/app/views/account/entries/_entry_group.html.erb @@ -0,0 +1,21 @@ +<%# locals: (date:, entries:, selectable: true, **opts) %> +
+
+
+ <% if selectable %> + <%= check_box_tag "#{date}_entries_selection", + class: ["maybe-checkbox maybe-checkbox--light", "hidden": entries.size == 0], + id: "selection_entry_#{date}", + data: { action: "bulk-select#toggleGroupSelection" } %> + <% end %> + + <%= tag.span "#{date.strftime('%b %d, %Y')} ยท #{entries.size}" %> +
+ + <%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %> +
+
+ <%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %> + <%= render transfer_entries(entries), selectable:, **opts %> +
+
diff --git a/app/views/account/transactions/_loading.html.erb b/app/views/account/entries/_loading.html.erb similarity index 100% rename from app/views/account/transactions/_loading.html.erb rename to app/views/account/entries/_loading.html.erb diff --git a/app/views/account/valuations/_valuation_ruler.html.erb b/app/views/account/entries/_ruler.html.erb similarity index 100% rename from app/views/account/valuations/_valuation_ruler.html.erb rename to app/views/account/entries/_ruler.html.erb diff --git a/app/views/account/transactions/_selection_bar.html.erb b/app/views/account/entries/_selection_bar.html.erb similarity index 79% rename from app/views/account/transactions/_selection_bar.html.erb rename to app/views/account/entries/_selection_bar.html.erb index 50655998..e3003813 100644 --- a/app/views/account/transactions/_selection_bar.html.erb +++ b/app/views/account/entries/_selection_bar.html.erb @@ -1,6 +1,6 @@
- <%= check_box_tag "transaction_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %> + <%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>

@@ -19,7 +19,12 @@ accept: t(".mark_transfers_confirm"), } } do |f| %> - <% end %> diff --git a/app/views/account/entries/edit.html.erb b/app/views/account/entries/edit.html.erb new file mode 100644 index 00000000..435f4564 --- /dev/null +++ b/app/views/account/entries/edit.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag dom_id(@entry) do %> + <%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %> +<% end %> diff --git a/app/views/account/entries/entryables/transaction/_show.html.erb b/app/views/account/entries/entryables/transaction/_show.html.erb new file mode 100644 index 00000000..9b9644d5 --- /dev/null +++ b/app/views/account/entries/entryables/transaction/_show.html.erb @@ -0,0 +1,113 @@ +<%# locals: (entry:) %> + +<% transaction, account = entry.account_transaction, entry.account %> + +<%= drawer do %> +
+
+
+

+ <%= format_money -entry.amount_money %> + <%= entry.currency %> +

+ + <% if entry.marked_as_transfer? %> + <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> + <% end %> +
+ + <%= entry.date.strftime("%A %d %B") %> +
+ +
+
+ +

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

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ <%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %> +
+ <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> + <%= f.date_field :date, label: t(".date_label"), max: Date.current, "data-auto-submit-form-target": "auto" %> + + <%= f.fields_for :entryable do |ef| %> + <% unless entry.marked_as_transfer? %> + <%= ef.collection_select :category_id, selectable_categories, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> + <%= ef.collection_select :merchant_id, selectable_merchants, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> + <% end %> + <% end %> + + <%= f.collection_select :account_id, selectable_accounts, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %> +
+ <% end %> +
+
+ +
+ +

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

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ <%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %> + + <%= f.fields_for :entryable do |ef| %> + <%= ef.select :tag_ids, + options_for_select(selectable_tags, transaction.tag_ids), + { + multiple: true, + label: t(".tags_label"), + class: "placeholder:text-gray-500" + }, + "data-auto-submit-form-target": "auto" %> + <%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %> + <% end %> + <% end %> +
+
+ +
+ +

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

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ <%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %> + <%= f.fields_for :entryable do |ef| %> +
+
+

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

+

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

+
+ +
+ <%= ef.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %> + +
+
+ <% end %> + <% end %> + + <% unless entry.marked_as_transfer? %> +
+
+

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

+

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

+
+ + <%= button_to t(".delete"), + account_entry_path(account, entry), + method: :delete, + class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", + data: { turbo_confirm: true, turbo_frame: "_top" } %> +
+ <% end %> +
+
+
+
+<% end %> diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/entries/entryables/transaction/_transaction.html.erb similarity index 65% rename from app/views/account/transactions/_transaction.html.erb rename to app/views/account/entries/entryables/transaction/_transaction.html.erb index 8d341a18..0c87eac2 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/entries/entryables/transaction/_transaction.html.erb @@ -1,26 +1,27 @@ -<%# locals: (transaction:, selectable: true, editable: true, short: false, show_tags: false) %> -<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4" do %> +<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %> +<% transaction, account = entry.account_transaction, entry.account %> - <% name_col_span = transaction.transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %> +
+ <% name_col_span = entry.marked_as_transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
<% if selectable %> - <%= check_box_tag dom_id(transaction, "selection"), + <%= check_box_tag dom_id(entry, "selection"), class: "maybe-checkbox maybe-checkbox--light", - data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> + data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> <% end %>
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
- <%= transaction.name[0].upcase %> + <%= entry.name[0].upcase %>
- <% if transaction.new_record? %> - <%= content_tag :p, transaction.name %> + <% if entry.new_record? %> + <%= content_tag :p, entry.name %> <% else %> - <%= link_to transaction.name, - account_transaction_path(transaction.account, transaction), + <%= link_to entry.name, + account_entry_path(account, entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> @@ -28,7 +29,7 @@ <% end %>
- <% if unconfirmed_transfer?(transaction) %> + <% if unconfirmed_transfer?(entry) %> <% if editable %> <%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: { turbo_confirm: { @@ -38,7 +39,7 @@ }, turbo_frame: "_top" } do |f| %> - <%= f.hidden_field "bulk_update[transaction_ids][]", value: transaction.id %> + <%= 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" %> @@ -50,7 +51,7 @@ <% end %>
- <% unless transaction.transfer? %> + <% unless entry.marked_as_transfer? %> <% unless short %>
"> <% if editable %> @@ -69,11 +70,11 @@ <% unless show_tags %> <%= tag.div class: short ? "col-span-4" : "col-span-3" do %> - <% if transaction.new_record? %> - <%= tag.p transaction.account.name %> + <% if entry.new_record? %> + <%= tag.p account.name %> <% else %> - <%= link_to transaction.account.name, - account_path(transaction.account, tab: "transactions"), + <%= link_to account.name, + account_path(account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %> <% end %> @@ -83,7 +84,7 @@
<%= content_tag :p, - format_money(-transaction.amount_money), - class: ["text-green-600": transaction.inflow?] %> + format_money(-entry.amount_money), + class: ["text-green-600": entry.inflow?] %>
-<% end %> +
diff --git a/app/views/account/entries/entryables/valuation/_edit.html.erb b/app/views/account/entries/entryables/valuation/_edit.html.erb new file mode 100644 index 00000000..dfd8dd2f --- /dev/null +++ b/app/views/account/entries/entryables/valuation/_edit.html.erb @@ -0,0 +1 @@ +<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %> diff --git a/app/views/account/entries/entryables/valuation/_form.html.erb b/app/views/account/entries/entryables/valuation/_form.html.erb new file mode 100644 index 00000000..0123e61d --- /dev/null +++ b/app/views/account/entries/entryables/valuation/_form.html.erb @@ -0,0 +1,24 @@ +<%# locals: (entry:) %> +<%= form_with model: [entry.account, entry], + data: { turbo_frame: "_top" }, + url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry), + builder: ActionView::Helpers::FormBuilder do |f| %> +
+
+
+ <%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %> +
+
+ <%= f.date_field :date, required: "required", max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %> + <%= f.number_field :amount, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %> + <%= f.hidden_field :currency, value: entry.account.currency %> + <%= f.hidden_field :entryable_type, value: entry.entryable_type %> +
+
+ +
+ <%= link_to t(".cancel"), valuation_account_entries_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %> + <%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %> +
+
+<% end %> diff --git a/app/views/account/entries/entryables/valuation/_new.html.erb b/app/views/account/entries/entryables/valuation/_new.html.erb new file mode 100644 index 00000000..2ea0290e --- /dev/null +++ b/app/views/account/entries/entryables/valuation/_new.html.erb @@ -0,0 +1,2 @@ +<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %> +
diff --git a/app/views/account/entries/entryables/valuation/_show.html.erb b/app/views/account/entries/entryables/valuation/_show.html.erb new file mode 100644 index 00000000..6ba1e419 --- /dev/null +++ b/app/views/account/entries/entryables/valuation/_show.html.erb @@ -0,0 +1 @@ +<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %> diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/entries/entryables/valuation/_valuation.html.erb similarity index 59% rename from app/views/account/valuations/_valuation.html.erb rename to app/views/account/entries/entryables/valuation/_valuation.html.erb index 7fa0021d..30bfa6ab 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/entries/entryables/valuation/_valuation.html.erb @@ -1,36 +1,42 @@ -<%= turbo_frame_tag dom_id(valuation) do %> +<%# locals: (entry:, **opts) %> + +<% account = entry.account %> + +<%= turbo_frame_tag dom_id(entry) do %> + <% is_oldest = entry.first_of_type? %> +
- <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: valuation_style(valuation).html_safe do %> - <%= lucide_icon valuation_icon(valuation), class: "w-4 h-4" %> + <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: entry_style(entry, is_oldest:).html_safe do %> + <%= lucide_icon entry_icon(entry, is_oldest:), class: "w-4 h-4" %> <% end %>
- <%= tag.p valuation.date, class: "text-gray-900 font-medium" %> - <%= tag.p valuation.oldest? ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %> + <%= tag.p entry.date, class: "text-gray-900 font-medium" %> + <%= tag.p is_oldest ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %>
- <%= tag.p format_money(valuation.value_money), class: "font-medium text-sm text-gray-900" %> + <%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-gray-900" %>
-
- <% if valuation.trend.direction.flat? %> +
+ <% if entry.trend.direction.flat? %> <%= tag.span t(".no_change"), class: "text-gray-500" %> <% else %> - <%= tag.span format_money(valuation.trend.value) %> - <%= tag.span "(#{valuation.trend.percent}%)" %> + <%= tag.span format_money(entry.trend.value) %> + <%= tag.span "(#{entry.trend.percent}%)" %> <% end %>
<%= contextual_menu do %>
- <%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_valuation_path(valuation.account, valuation) %> + <%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_entry_path(account, entry) %> <%= contextual_menu_destructive_item t(".delete_entry"), - account_valuation_path(valuation.account, valuation), + account_entry_path(account, entry), turbo_frame: "_top", turbo_confirm: { title: t(".confirm_title"), diff --git a/app/views/account/entries/new.html.erb b/app/views/account/entries/new.html.erb new file mode 100644 index 00000000..e35beb75 --- /dev/null +++ b/app/views/account/entries/new.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag dom_id(@entry) do %> + <%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %> +<% end %> diff --git a/app/views/account/entries/show.html.erb b/app/views/account/entries/show.html.erb new file mode 100644 index 00000000..71c695ef --- /dev/null +++ b/app/views/account/entries/show.html.erb @@ -0,0 +1 @@ +<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %> diff --git a/app/views/account/transactions/index.html.erb b/app/views/account/entries/transactions.html.erb similarity index 78% rename from app/views/account/transactions/index.html.erb rename to app/views/account/entries/transactions.html.erb index 371a6bc4..ea824d3e 100644 --- a/app/views/account/transactions/index.html.erb +++ b/app/views/account/entries/transactions.html.erb @@ -12,15 +12,15 @@
"> - <% if @transactions.empty? %> + <% if @transaction_entries.empty? %>

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

<% else %>
- <% group_transactions_by_date(@transactions).each do |date, group| %> - <%= render "transaction_group", date:, transactions: group[:transactions], transfers: group[:transfers] %> + <% @transaction_entries.group_by(&:date).each do |date, entries| %> + <%= render "entry_group", date:, entries: entries %> <% end %>
<% end %> diff --git a/app/views/account/valuations/index.html.erb b/app/views/account/entries/valuations.html.erb similarity index 69% rename from app/views/account/valuations/index.html.erb rename to app/views/account/entries/valuations.html.erb index e4062d9f..f993d037 100644 --- a/app/views/account/valuations/index.html.erb +++ b/app/views/account/entries/valuations.html.erb @@ -2,8 +2,8 @@
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %> - <%= link_to new_account_valuation_path(@account), - data: { turbo_frame: dom_id(Account::Valuation.new) }, + <%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"), + data: { turbo_frame: dom_id(@account.entries.account_valuations.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> <%= tag.span t(".new_entry"), class: "text-sm" %> @@ -19,13 +19,13 @@
- <%= turbo_frame_tag dom_id(Account::Valuation.new) %> + <%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %> - <% valuations = @account.valuations.reverse_chronological %> - <% if valuations.any? %> - <%= render partial: "valuation", - collection: @account.valuations.reverse_chronological, - spacer_template: "valuation_ruler" %> + <% if @valuation_entries.any? %> + <%= render partial: "account/entries/entryables/valuation/valuation", + collection: @valuation_entries, + as: :entry, + spacer_template: "ruler" %> <% else %>

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

<% end %> diff --git a/app/views/account/transaction/rows/show.html.erb b/app/views/account/transaction/rows/show.html.erb deleted file mode 100644 index 473d8200..00000000 --- a/app/views/account/transaction/rows/show.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render "account/transactions/transaction", transaction: @transaction %> diff --git a/app/views/account/transactions/_transaction_group.html.erb b/app/views/account/transactions/_transaction_group.html.erb deleted file mode 100644 index 54ab5520..00000000 --- a/app/views/account/transactions/_transaction_group.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<%# locals: (date:, transactions:, transfers: [], selectable: true, **transaction_opts) %> -
-
-
- <% if selectable %> - <%= check_box_tag "#{date}_transactions_selection", - class: ["maybe-checkbox maybe-checkbox--light", "hidden": transactions.count == 0], - id: "selection_transaction_#{date}", - data: { action: "bulk-select#toggleGroupSelection" } %> - <% end %> - - <%= tag.span "#{date.strftime('%b %d, %Y')} ยท #{transactions.size + (transfers.size * 2)}" %> -
- - <%= totals_by_currency(collection: transactions, money_method: :amount_money, negate: true) %> -
-
- <%= render transactions, selectable:, **transaction_opts.except(:selectable) %> - <%= render transfers %> -
-
diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb deleted file mode 100644 index 5d849dde..00000000 --- a/app/views/account/transactions/show.html.erb +++ /dev/null @@ -1,103 +0,0 @@ -<%= drawer do %> -
-
-
-

- <%= format_money -@transaction.amount_money %> - <%= @transaction.currency %> -

- - <% if @transaction.marked_as_transfer %> - <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> - <% end %> -
- - <%= @transaction.date.strftime("%A %d %B") %> -
- -
-
- -

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

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> -
- -
- <%= form_with model: [@account, @transaction], url: account_transaction_path, html: { data: { controller: "auto-submit-form" } } do |f| %> -
- <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> - <%= f.date_field :date, label: t(".date_label"), max: Date.today, "data-auto-submit-form-target": "auto" %> - - <% unless @transaction.marked_as_transfer %> - <%= f.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> - <%= f.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> - <% end %> - - <%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %> -
- <% end %> -
-
- -
- -

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

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> -
- -
- <%= form_with model: [@account, @transaction], url: account_transaction_path, html: { data: { controller: "auto-submit-form" } } do |f| %> - <%= f.select :tag_ids, - options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), @transaction.tag_ids), - { - multiple: true, - label: t(".tags_label"), - class: "placeholder:text-gray-500" - }, - "data-auto-submit-form-target": "auto" %> - <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %> - <% end %> -
-
- -
- -

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

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> -
- -
- - <%= form_with model: [@account, @transaction], url: account_transaction_path, html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %> -
-
-

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

-

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

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

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

-

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

-
- - <%= button_to t(".delete"), - account_transaction_path(@account, @transaction), - method: :delete, - class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", - data: { turbo_confirm: true, turbo_frame: "_top" } %> -
- <% end %> -
-
-
-
-<% end %> diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index e8cdd61e..ddd89c79 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -19,11 +19,11 @@
- <%= f.text_field :name, value: transfer.transactions.first&.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> + <%= f.text_field :name, value: transfer.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> <%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> <%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.money_field :amount_money, label: t(".amount"), required: true %> - <%= f.date_field :date, value: transfer.transactions.first&.date, label: t(".date"), required: true, max: Date.current %> + <%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/account/transfers/_transfer.html.erb b/app/views/account/transfers/_transfer.html.erb index 3a04b3f5..3b2bcc28 100644 --- a/app/views/account/transfers/_transfer.html.erb +++ b/app/views/account/transfers/_transfer.html.erb @@ -1,34 +1,49 @@ -<%= turbo_frame_tag dom_id(transfer), class: "block" do %> -
- -
- <%= button_to account_transfer_path(transfer), - method: :delete, - class: "flex items-center group/transfer", - data: { - turbo_frame: "_top", - turbo_confirm: { - title: t(".remove_title"), - body: t(".remove_body"), - confirm: t(".remove_confirm") - } - } do %> - <%= lucide_icon "arrow-left-right", class: "group-hover/transfer:hidden w-5 h-5 text-gray-500" %> - <%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-5 h-5 text-gray-500" %> - <% end %> +<%# locals: (transfer:, selectable: true, editable: true, short: false, **opts) %> -
- <%= tag.p t(".transfer_name", from_account: transfer.outflow_transaction&.account&.name, to_account: transfer.inflow_transaction&.account&.name) %> +<%= turbo_frame_tag dom_id(transfer) do %> +
+
+ <% if selectable %> + <%= check_box_tag dom_id(transfer, "selection"), + disabled: true, + class: "mr-3 cursor-not-allowed maybe-checkbox maybe-checkbox--light" %> + <% end %> + + <%= tag.div class: short ? "max-w-[250px]" : "max-w-[325px]" do %> +
+ <%= circle_logo("T") %> + + <%= tag.p transfer.name, class: "truncate text-gray-900" %>
-
+ <% end %> - <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> -
+ <%= button_to account_transfer_path(transfer), + method: :delete, + class: "ml-2 flex items-center group/transfer hover:bg-gray-50 rounded-md p-1", + data: { + turbo_frame: "_top", + turbo_confirm: { + title: t(".remove_title"), + body: t(".remove_body"), + confirm: t(".remove_confirm") + } + } do %> -
- <% transfer.transactions.each do |transaction| %> - <%= render transaction, selectable: false, editable: false %> + <%= lucide_icon "link-2", class: "group-hover/transfer:hidden w-4 h-4 text-gray-500" %> + <%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-4 h-4 text-gray-500" %> <% end %>
-
+ + <% unless short %> +
+ <%= circle_logo(transfer.from_name[0].upcase, size: "sm") %> + + <%= circle_logo(transfer.to_name[0].upcase, size: "sm") %> +
+ <% end %> + +
"> + <%= tag.p format_money(transfer.amount_money), class: "font-medium" %> +
+
<% end %> diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb deleted file mode 100644 index 6027bcdd..00000000 --- a/app/views/account/valuations/_form.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%# locals: (valuation:) %> -<%= form_with model: valuation, - data: { turbo_frame: "_top" }, - url: valuation.new_record? ? account_valuations_path(valuation.account) : account_valuation_path(valuation.account, valuation), - builder: ActionView::Helpers::FormBuilder do |f| %> -
-
-
- <%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %> -
-
- <%= f.date_field :date, required: "required", max: Date.today, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %> - <%= f.number_field :value, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %> - <%= f.hidden_field :currency, value: valuation.account.currency %> -
-
- -
- <%= link_to t(".cancel"), account_valuations_path(valuation.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %> - <%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %> -
-
-<% end %> diff --git a/app/views/account/valuations/_loading.html.erb b/app/views/account/valuations/_loading.html.erb deleted file mode 100644 index 4f49ca61..00000000 --- a/app/views/account/valuations/_loading.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
-
- <%= tag.p t(".loading"), class: "text-gray-500 animate-pulse text-sm" %> -
-
diff --git a/app/views/account/valuations/edit.html.erb b/app/views/account/valuations/edit.html.erb deleted file mode 100644 index afa3877d..00000000 --- a/app/views/account/valuations/edit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= turbo_frame_tag dom_id(@valuation) do %> - <%= render "form", valuation: @valuation %> -<% end %> diff --git a/app/views/account/valuations/new.html.erb b/app/views/account/valuations/new.html.erb deleted file mode 100644 index 29332c17..00000000 --- a/app/views/account/valuations/new.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -<%= turbo_frame_tag dom_id(@valuation) do %> - <%= render "form", valuation: @valuation %> -
-<% end %> diff --git a/app/views/account/valuations/show.html.erb b/app/views/account/valuations/show.html.erb deleted file mode 100644 index 22ab5efe..00000000 --- a/app/views/account/valuations/show.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render "valuation", valuation: @valuation %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 58d31cfd..1c74929e 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -81,12 +81,12 @@
<% if selected_tab == "transactions" %> - <%= turbo_frame_tag dom_id(@account, "transactions"), src: account_transactions_path(@account) do %> - <%= render "account/transactions/loading" %> + <%= turbo_frame_tag dom_id(@account, "transactions"), src: transaction_account_entries_path(@account) do %> + <%= render "account/entries/loading" %> <% end %> <% else %> - <%= turbo_frame_tag dom_id(@account, "valuations"), src: account_valuations_path(@account) do %> - <%= render "account/valuations/loading" %> + <%= turbo_frame_tag dom_id(@account, "valuations"), src: valuation_account_entries_path(@account) do %> + <%= render "account/entries/loading" %> <% end %> <% end %>
diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb index bca65ceb..eb75cf91 100644 --- a/app/views/category/dropdowns/_row.html.erb +++ b/app/views/category/dropdowns/_row.html.erb @@ -2,7 +2,7 @@ <% is_selected = category.id === @selected_category&.id %> <%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %> - <%= button_to account_transaction_row_path(@transaction.account, @transaction, transaction: { category_id: category.id }), method: :patch, data: { turbo_frame: dom_id(@transaction) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %> + <%= button_to account_entry_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %> <%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %> diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index fd966334..b567b690 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -25,10 +25,10 @@ <% end %> <% if @transaction.category %> - <%= button_to account_transaction_row_path(@transaction.account, @transaction), + <%= button_to account_entry_path(@transaction.entry.account, @transaction.entry), method: :patch, - data: { turbo_frame: dom_id(@transaction) }, - params: { transaction: { category_id: nil } }, + data: { turbo_frame: dom_id(@transaction.entry) }, + params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } }, class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %> <%= lucide_icon "minus", class: "w-5 h-5" %> diff --git a/app/views/imports/confirm.html.erb b/app/views/imports/confirm.html.erb index cc47b42b..46491abb 100644 --- a/app/views/imports/confirm.html.erb +++ b/app/views/imports/confirm.html.erb @@ -9,12 +9,11 @@
- <% transactions = @import.dry_run %> - <% group_transactions_by_date(transactions).each do |date, group| %> - <%= render "account/transactions/transaction_group", + <% transaction_entries = @import.dry_run %> + <% transaction_entries.group_by(&:date).each do |date, transactions| %> + <%= render "account/entries/entry_group", date: date, - transactions: group[:transactions], - transfers: group[:transfers], + entries: transaction_entries, show_tags: true, selectable: false, editable: false %> diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 8f0435dc..c6be1f0f 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -25,7 +25,7 @@ <% end %>
- <%= previous_setting("Rules", account_transaction_rules_path) %> + <%= previous_setting("Rules", rules_transactions_path) %> <%= next_setting("What's new", changelog_path) %>
diff --git a/app/views/merchants/index.html.erb b/app/views/merchants/index.html.erb index 8e3eca22..c615ad4e 100644 --- a/app/views/merchants/index.html.erb +++ b/app/views/merchants/index.html.erb @@ -35,6 +35,6 @@
<%= previous_setting("Categories", categories_path) %> - <%= next_setting("Rules", account_transaction_rules_path) %> + <%= next_setting("Rules", rules_transactions_path) %>
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 5909d1fb..66b24c9d 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -156,17 +156,16 @@

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

- <% if @transactions.empty? %> + <% if @transaction_entries.empty? %>

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

<% else %>
- <% group_transactions_by_date(@transactions).each do |date, group| %> - <%= render "account/transactions/transaction_group", + <% @transaction_entries.group_by(&:date).each do |date, transactions| %> + <%= render "account/entries/entry_group", date: date, - transactions: group[:transactions], - transfers: group[:transfers], + entries: transactions, selectable: false, editable: false, short: true %> diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb index 333ee3bc..2c0d823e 100644 --- a/app/views/settings/_nav.html.erb +++ b/app/views/settings/_nav.html.erb @@ -56,7 +56,7 @@ <%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
  • - <%= sidebar_link_to t(".rules_label"), account_transaction_rules_path, icon: "list-checks" %> + <%= sidebar_link_to t(".rules_label"), rules_transactions_path, icon: "list-checks" %>
  • <%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %> diff --git a/app/views/shared/_circle_logo.html.erb b/app/views/shared/_circle_logo.html.erb new file mode 100644 index 00000000..328cb476 --- /dev/null +++ b/app/views/shared/_circle_logo.html.erb @@ -0,0 +1,13 @@ +<%# locals: (name:, hex: nil, size: "md") %> + +<% size_classes = { + "sm" => "w-6 h-6", + "md" => "w-8 h-8", + "lg" => "w-10 h-10", + "full" => "w-full h-full" +} %> + +<%= tag.div style: mixed_hex_styles(hex), + class: [size_classes[size], "flex shrink-0 items-center justify-center rounded-full"] do %> + <%= tag.span name[0].upcase, class: ["font-medium", size == "sm" ? "text-xs" : "text-sm"] %> +<% end %> diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 46f00a38..dcd4c08c 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with model: @transaction, url: transactions_path, scope: "transaction", data: { turbo_frame: "_top" } do |f| %> +<%= form_with model: @entry, url: transactions_path, data: { turbo_frame: "_top" } do |f| %>
    <%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %> @@ -14,7 +14,10 @@ <%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> <%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %> <%= f.money_field :amount_money, label: t(".amount"), required: true %> - <%= f.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> + <%= f.hidden_field :entryable_type, value: "Account::Transaction" %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> + <% end %> <%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
    diff --git a/app/views/transactions/_pagination.html.erb b/app/views/transactions/_pagination.html.erb index ae481645..c588bfa4 100644 --- a/app/views/transactions/_pagination.html.erb +++ b/app/views/transactions/_pagination.html.erb @@ -1,39 +1,39 @@ <%# locals: (pagy:) %> -