class Entry < ApplicationRecord include Monetizable, Enrichable monetize :amount belongs_to :account belongs_to :transfer, optional: true belongs_to :import, optional: true delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable validates :date, :name, :amount, :currency, presence: true validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } # To ensure we can recreate balance history solely from Entries, all entries must post on or before the current anchor (i.e. "Current balance"), # and after the opening anchor (i.e. "Opening balance"). This domain invariant should be enforced by the Account model when adding/modifying entries. validate :date_after_opening_anchor validate :date_on_or_before_current_anchor scope :visible, -> { joins(:account).where(accounts: { status: [ "draft", "active" ] }) } scope :chronological, -> { order( date: :asc, Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc, created_at: :asc ) } scope :reverse_chronological, -> { order( date: :desc, Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc, created_at: :desc ) } def classification amount.negative? ? "income" : "expense" end def lock_saved_attributes! super entryable.lock_saved_attributes! end def sync_account_later sync_start_date = [ date_previously_was, date ].compact.min unless destroyed? account.sync_later(window_start_date: sync_start_date) end def entryable_name_short entryable_type.demodulize.underscore end def balance_trend(entries, balances) Balance::TrendCalculator.new(self, entries, balances).trend end def linked? plaid_id.present? end class << self def search(params) EntrySearch.new(params).build_query(all) end # arbitrary cutoff date to avoid expensive sync operations def min_supported_date 30.years.ago.to_date end def bulk_update!(bulk_update_params) bulk_attributes = { date: bulk_update_params[:date], notes: bulk_update_params[:notes], entryable_attributes: { category_id: bulk_update_params[:category_id], merchant_id: bulk_update_params[:merchant_id], tag_ids: bulk_update_params[:tag_ids] }.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 entry.lock_saved_attributes! entry.entryable.lock_attr!(:tag_ids) if entry.transaction? && entry.transaction.tags.any? end end all.size end end private def date_after_opening_anchor return unless account && date # Skip validation for anchor valuations themselves return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor]) opening_anchor_date = account.valuations .joins(:entry) .where(kind: "opening_anchor") .pluck(Arel.sql("entries.date")) .first if opening_anchor_date && date <= opening_anchor_date errors.add(:date, "must be after the opening balance date (#{opening_anchor_date})") end end def date_on_or_before_current_anchor return unless account && date # Skip validation for anchor valuations themselves return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor]) current_anchor_date = account.valuations .joins(:entry) .where(kind: "current_anchor") .pluck(Arel.sql("entries.date")) .first if current_anchor_date && date > current_anchor_date errors.add(:date, "must be on or before the current balance date (#{current_anchor_date})") end end end