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) %> +