core/app/models/spree/store.rb

Summary

Maintainability
A
3 hrs
Test Coverage
A
95%
module Spree
  class Store < Spree::Base
    include Spree::TranslatableResource
    if defined?(Spree::Webhooks::HasWebhooks)
      include Spree::Webhooks::HasWebhooks
    end
    if defined?(Spree::Security::Stores)
      include Spree::Security::Stores
    end

    TRANSLATABLE_FIELDS = %i[name meta_description meta_keywords seo_title facebook
                             twitter instagram customer_support_email description
                             address contact_phone new_order_notifications_email].freeze
    translates(*TRANSLATABLE_FIELDS)

    self::Translation.class_eval do
      acts_as_paranoid
      # deleted translation values still need to be accessible - remove deleted_at scope
      default_scope { unscope(where: :deleted_at) }
    end

    typed_store :settings, coder: ActiveRecord::TypedStore::IdentityCoder do |s|
      # Spree Digital Asset Configurations
      s.boolean :limit_digital_download_count, default: true, null: false
      s.boolean :limit_digital_download_days, default: true, null: false
      s.integer :digital_asset_authorized_clicks, default: 5, null: false # number of times a customer can download a digital file.
      s.integer :digital_asset_authorized_days, default: 7, null: false # number of days after initial purchase the customer can download a file.
      s.integer :digital_asset_link_expire_time, default: 300, null: false # 5 minutes in seconds
    end

    attr_accessor :skip_validate_not_last

    acts_as_paranoid

    has_many :orders, class_name: 'Spree::Order'
    has_many :line_items, through: :orders, class_name: 'Spree::LineItem'
    has_many :shipments, through: :orders, class_name: 'Spree::Shipment'
    has_many :payments, through: :orders, class_name: 'Spree::Payment'
    has_many :return_authorizations, through: :orders, class_name: 'Spree::ReturnAuthorization'
    # has_many :reimbursements, through: :orders, class_name: 'Spree::Reimbursement' FIXME: we should fetch this via Customer Return

    has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod'
    has_many :payment_methods, through: :store_payment_methods, class_name: 'Spree::PaymentMethod'

    has_many :cms_pages, class_name: 'Spree::CmsPage'
    has_many :cms_sections, through: :cms_pages, class_name: 'Spree::CmsSection'

    has_many :menus, class_name: 'Spree::Menu'
    has_many :menu_items, through: :menus, class_name: 'Spree::MenuItem'

    has_many :store_products, class_name: 'Spree::StoreProduct'
    has_many :products, through: :store_products, class_name: 'Spree::Product'
    has_many :product_properties, through: :products, class_name: 'Spree::ProductProperty'
    has_many :variants, through: :products, class_name: 'Spree::Variant', source: :variants_including_master
    has_many :stock_items, through: :variants, class_name: 'Spree::StockItem'
    has_many :inventory_units, through: :variants, class_name: 'Spree::InventoryUnit'
    has_many :customer_returns, class_name: 'Spree::CustomerReturn', inverse_of: :store

    has_many :store_credits, class_name: 'Spree::StoreCredit'
    has_many :store_credit_events, through: :store_credits, class_name: 'Spree::StoreCreditEvent'

    has_many :taxonomies, class_name: 'Spree::Taxonomy'
    has_many :taxons, through: :taxonomies, class_name: 'Spree::Taxon'

    has_many :store_promotions, class_name: 'Spree::StorePromotion'
    has_many :promotions, through: :store_promotions, class_name: 'Spree::Promotion'

    has_many :wishlists, class_name: 'Spree::Wishlist'

    has_many :data_feeds, class_name: 'Spree::DataFeed'

    belongs_to :default_country, class_name: 'Spree::Country'
    belongs_to :checkout_zone, class_name: 'Spree::Zone'

    with_options presence: true do
      validates :name, :url, :mail_from_address, :default_currency, :code
    end

    validates :digital_asset_authorized_clicks, numericality: { only_integer: true, greater_than: 0 }
    validates :digital_asset_authorized_days, numericality: { only_integer: true, greater_than: 0 }
    validates :code, uniqueness: { case_sensitive: false, conditions: -> { with_deleted } }
    validates :mail_from_address, email: { allow_blank: false }

    # FIXME: we should remove this condition in v5
    if !ENV['SPREE_DISABLE_DB_CONNECTION'] &&
        connected? &&
        table_exists? &&
        connection.column_exists?(:spree_stores, :new_order_notifications_email)
      validates :new_order_notifications_email, email: { allow_blank: true }
    end

    default_scope { order(created_at: :asc) }

    has_one :logo, class_name: 'Spree::StoreLogo', dependent: :destroy, as: :viewable
    accepts_nested_attributes_for :logo, reject_if: :all_blank

    has_one :mailer_logo, class_name: 'Spree::StoreMailerLogo', dependent: :destroy, as: :viewable
    accepts_nested_attributes_for :mailer_logo, reject_if: :all_blank

    has_one :favicon_image, class_name: 'Spree::StoreFaviconImage', dependent: :destroy, as: :viewable
    accepts_nested_attributes_for :favicon_image, reject_if: :all_blank

    before_save :ensure_default_exists_and_is_unique
    before_save :ensure_supported_currencies, :ensure_supported_locales, :ensure_default_country
    before_destroy :validate_not_last, unless: :skip_validate_not_last
    before_destroy :pass_default_flag_to_other_store

    scope :by_url, ->(url) { where('url like ?', "%#{url}%") }

    after_commit :clear_cache

    def self.current(url = nil)
      Spree::Deprecation.warn(<<-DEPRECATION, caller)
        `Spree::Store.current` is deprecated and will be removed in Spree 5.0
        Please use `Spree::Stores::FindCurrent.new(url: "https://example.com").execute` instead
      DEPRECATION
      Stores::FindCurrent.new(url: url).execute
    end

    # FIXME: we need to drop `or_initialize` in v5
    # this behaviour is very buggy and unpredictable
    def self.default
      Rails.cache.fetch('default_store') do
        # workaround for Mobility bug with first_or_initialize
        if where(default: true).any?
          where(default: true).first
        else
          new(default: true)
        end
      end
    end

    def self.available_locales
      Rails.cache.fetch('stores_available_locales') do
        Spree::Store.all.map(&:supported_locales_list).flatten.uniq
      end
    end

    def default_menu(location)
      menu = menus.find_by(location: location, locale: default_locale) || menus.find_by(location: location)

      menu.root if menu.present?
    end

    def supported_currencies_list
      @supported_currencies_list ||= (read_attribute(:supported_currencies).to_s.split(',') << default_currency).sort.map(&:to_s).map do |code|
        ::Money::Currency.find(code.strip)
      end.uniq.compact
    end

    def homepage(requested_locale)
      cms_pages.by_locale(requested_locale).find_by(type: 'Spree::Cms::Pages::Homepage') ||
        cms_pages.by_locale(default_locale).find_by(type: 'Spree::Cms::Pages::Homepage') ||
        cms_pages.find_by(type: 'Spree::Cms::Pages::Homepage')
    end

    def seo_meta_description
      if meta_description.present?
        meta_description
      elsif seo_title.present?
        seo_title
      else
        name
      end
    end

    def supported_locales_list
      # TODO: add support of multiple supported languages to a single Store
      @supported_locales_list ||= (read_attribute(:supported_locales).to_s.split(',') << default_locale).compact.uniq.sort
    end

    def unique_name
      @unique_name ||= "#{name} (#{code})"
    end

    def formatted_url
      return if url.blank?

      @formatted_url ||= if url.match(/http:\/\/|https:\/\//)
                           url
                         else
                           Rails.env.development? || Rails.env.test? ? "http://#{url}" : "https://#{url}"
                         end
    end

    def countries_available_for_checkout
      @countries_available_for_checkout ||= checkout_zone.try(:country_list) || Spree::Country.all
    end

    def states_available_for_checkout(country)
      checkout_zone.try(:state_list_for, country) || country.states
    end

    def checkout_zone_or_default
      Spree::Deprecation.warn('Store#checkout_zone_or_default is deprecated and will be removed in Spree 5')

      @checkout_zone_or_default ||= checkout_zone || Spree::Zone.default_checkout_zone
    end

    def favicon
      return unless favicon_image&.attachment&.attached?

      favicon_image.attachment.variant(resize_to_limit: [32, 32])
    end

    def can_be_deleted?
      self.class.where.not(id: id).any?
    end

    private

    def ensure_default_exists_and_is_unique
      if default
        Store.where.not(id: id).update_all(default: false)
      elsif Store.where(default: true).count.zero?
        self.default = true
      end
    end

    def ensure_supported_locales
      return unless attributes.keys.include?('supported_locales')
      return if supported_locales.present?
      return if default_locale.blank?

      self.supported_locales = default_locale
    end

    def ensure_supported_currencies
      return unless attributes.keys.include?('supported_currencies')
      return if supported_currencies.present?
      return if default_currency.blank?

      self.supported_currencies = default_currency
    end

    def validate_not_last
      unless can_be_deleted?
        errors.add(:base, :cannot_destroy_only_store)
        throw(:abort)
      end
    end

    def pass_default_flag_to_other_store
      if default? && can_be_deleted?
        self.class.where.not(id: id).first.update!(default: true)
        self.default = false
      end
    end

    def clear_cache
      Rails.cache.delete('default_store')
      Rails.cache.delete('stores_available_locales')
    end

    def ensure_default_country
      return unless has_attribute?(:default_country_id)
      return if default_country.present? && (checkout_zone.blank? || checkout_zone.country_list.blank? || checkout_zone.country_list.include?(default_country))

      self.default_country = if checkout_zone.present? && checkout_zone.country_list.any?
                               checkout_zone.country_list.first
                             else
                               Country.find_by(iso: 'US') || Country.first
                             end
    end
  end
end