Class: Spree::PriceList

Inherits:
Object
  • Object
show all
Includes:
SingleStoreResource
Defined in:
app/models/spree/price_list.rb

Constant Summary collapse

MATCH_POLICIES =
%w[all any].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#prices_attributesObject

Override default nested attributes to use bulk_update_prices for performance



22
23
24
# File 'app/models/spree/price_list.rb', line 22

def prices_attributes
  @prices_attributes
end

Class Method Details

.for_context(context) ⇒ Object

Returns price lists applicable for a given pricing context

  • active: always applies (within date range)

  • scheduled: applies only within starts_at/ends_at date range



112
113
114
115
116
117
118
# File 'app/models/spree/price_list.rb', line 112

def self.for_context(context)
  timezone = context.store&.preferred_timezone || 'UTC'
  for_store(context.store)
    .with_status(:active, :scheduled)
    .current(timezone)
    .by_position
end

.match_policiesObject



120
121
122
# File 'app/models/spree/price_list.rb', line 120

def self.match_policies
  MATCH_POLICIES.map { |key| [Spree.t(key), key] }
end

Instance Method Details

#active_or_scheduled?Boolean

Returns true if the price list is active or scheduled

Returns:

  • (Boolean)


152
153
154
# File 'app/models/spree/price_list.rb', line 152

def active_or_scheduled?
  active? || scheduled?
end

#add_products(product_ids) ⇒ void

This method returns an undefined value.

Adds products to the list, materializing a placeholder price (amount nil) for every variant × store currency.

Parameters:

  • product_ids (Array<String>)

    raw product PKs



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'app/models/spree/price_list.rb', line 167

def add_products(product_ids)
  return if product_ids.blank?

  currencies = store.supported_currencies_list.map(&:iso_code)
  variant_ids = Spree::Variant.eligible.where(product_id: product_ids).distinct.pluck(:id)
  return if variant_ids.empty?

  existing = prices.where(variant_id: variant_ids)
                   .pluck(:variant_id, :currency)
                   .to_set

  now = Time.current

  prices_to_insert = variant_ids.flat_map do |variant_id|
    currencies.filter_map do |currency|
      next if existing.include?([variant_id, currency])

      {
        variant_id: variant_id,
        currency: currency,
        amount: nil,
        price_list_id: id,
        created_at: now,
        updated_at: now
      }
    end
  end

  return if prices_to_insert.empty?

  # Use upsert_all with on_duplicate: :skip to handle race conditions
  Spree::Price.upsert_all(prices_to_insert, on_duplicate: :skip)
  touch_variants(variant_ids)
  touch
end

#applicable?(context) ⇒ Boolean

Returns true if the price list is applicable to the context

Parameters:

Returns:

  • (Boolean)


127
128
129
130
131
132
# File 'app/models/spree/price_list.rb', line 127

def applicable?(context)
  return false unless active_or_scheduled?
  return false unless within_date_range?(context.date || Time.current)

  rules_applicable?(context)
end

#bulk_update_prices(prices_attributes) ⇒ Boolean

Bulk update prices using upsert_all for performance

Parameters:

  • prices_attributes (Array<Hash>)

    array of price attributes with :id, :amount, :compare_at_amount

Returns:

  • (Boolean)

    true if successful



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'app/models/spree/price_list.rb', line 226

def bulk_update_prices(prices_attributes)
  return true if prices_attributes.blank?

  records_to_upsert = []
  variant_ids = Set.new

  # Get current values for comparison
  price_ids = prices_attributes.map { |a| a[:id] || a['id'] }.compact.map(&:to_i)
  current_values = prices.where(id: price_ids).pluck(:id, :amount, :compare_at_amount).to_h { |id, amount, compare_at| [id, { amount: amount, compare_at_amount: compare_at }] }

  prices_attributes.each do |attrs|
    attrs = (attrs.respond_to?(:to_unsafe_h) ? attrs.to_unsafe_h : attrs.to_h).with_indifferent_access
    next if attrs[:id].blank?

    price_id = attrs[:id].to_i
    # Reject rows that aren't in *this* list's prices — `upsert_all`
    # otherwise keys solely by primary id and would silently cross
    # list boundaries.
    next unless current_values.key?(price_id)

    current = current_values[price_id]

    # Parse amounts using LocalizedNumber for proper decimal handling
    amount = attrs[:amount].present? ? Spree::LocalizedNumber.parse(attrs[:amount]) : nil
    compare_at_amount = attrs[:compare_at_amount].present? ? Spree::LocalizedNumber.parse(attrs[:compare_at_amount]) : nil

    # Clear compare_at_amount if it equals amount
    compare_at_amount = nil if compare_at_amount == amount

    # Skip if nothing changed
    next if amount == current[:amount] && compare_at_amount == current[:compare_at_amount]

    records_to_upsert << {
      id: price_id,
      variant_id: attrs[:variant_id].to_i,
      currency: attrs[:currency],
      amount: amount,
      compare_at_amount: compare_at_amount,
      price_list_id: id
    }

    variant_ids << attrs[:variant_id].to_i
  end

  return true if records_to_upsert.empty?

  opts = { update_only: [:amount, :compare_at_amount], on_duplicate: :update }
  opts[:unique_by] = :id unless mysql_adapter?

  Spree::Price.upsert_all(records_to_upsert, **opts)

  touch_variants(variant_ids.to_a)
  true
end

#currently_active?Boolean

Returns true if the price list is currently in effect (active, or scheduled and within date range)

Returns:

  • (Boolean)


158
159
160
# File 'app/models/spree/price_list.rb', line 158

def currently_active?
  active_or_scheduled? && within_date_range?(Time.current)
end

#prices=(rows) ⇒ void

This method returns an undefined value.

Flat-payload writer for ‘prices`. Bulk-upserts the listed rows in `after_save` so newly-added products have their placeholder rows materialized first. Nullability contract:

- `nil` → no-op
- `[]`  → clear every override on this list
- `[…]` → upsert listed rows, leave the rest alone

Parameters:



62
63
64
65
66
67
68
69
70
71
# File 'app/models/spree/price_list.rb', line 62

def prices=(rows)
  first = Array(rows).first
  return super(rows) if first.is_a?(Spree::Price)
  return if rows.nil?

  @pending_prices = Array(rows).map do |row|
    row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h.with_indifferent_access : row.with_indifferent_access
  end
  @pending_prices_clear = rows.empty?
end

#product_ids=(ids) ⇒ void

This method returns an undefined value.

Reconciles list membership. Removes prices for products no longer in ‘ids` and adds placeholder prices for the new ones.

Parameters:

  • ids (Array<String>)

    raw product PKs (prefixed strings are resolved upstream by ‘Spree::Base#assign_attributes`).



49
50
51
# File 'app/models/spree/price_list.rb', line 49

def product_ids=(ids)
  @pending_product_ids = Array(ids).compact.uniq
end

#product_prefixed_idsArray<String>

Returns prefixed product ids in this list, encoded inline to avoid hydrating N Product records.

Returns:

  • (Array<String>)

    prefixed product ids in this list, encoded inline to avoid hydrating N Product records.



38
39
40
41
# File 'app/models/spree/price_list.rb', line 38

def product_prefixed_ids
  prefix = Spree::Product._prefix_id_prefix
  product_ids.sort.map { |pk| "#{prefix}_#{Spree::PrefixedId::SQIDS.encode([pk])}" }
end

#remove_products(product_ids) ⇒ void

This method returns an undefined value.

Removes products from the list. Hard-deletes their prices so the unique index doesn’t block re-adding the same products later (acts_as_paranoid would leave soft-deleted rows blocking the ‘(variant_id, currency, price_list_id)` slot).

Parameters:

  • product_ids (Array<String>)

    raw product PKs



210
211
212
213
214
215
216
217
218
219
220
221
# File 'app/models/spree/price_list.rb', line 210

def remove_products(product_ids)
  return if product_ids.blank?

  variant_ids = Spree::Variant.where(product_id: product_ids).distinct.pluck(:id)
  return if variant_ids.empty?

  # Use delete_all for hard delete - this bypasses acts_as_paranoid
  # which is required for the unique index to work when re-adding products
  prices.where(variant_id: variant_ids).delete_all
  touch_variants(variant_ids)
  touch
end

#rules=(rows) ⇒ Object

Flat-payload writer for ‘rules`. See TypedAssociations#assign_typed_association.



75
76
77
# File 'app/models/spree/price_list.rb', line 75

def rules=(rows)
  assign_typed_association(:price_rules, rows)
end

#rules_applicable?(context) ⇒ Boolean

Returns true if the price list rules are applicable to the context

Parameters:

Returns:

  • (Boolean)


137
138
139
140
141
142
143
144
145
146
147
148
# File 'app/models/spree/price_list.rb', line 137

def rules_applicable?(context)
  return true if price_rules.none?

  case match_policy
  when 'all'
    price_rules.all? { |rule| rule.applicable?(context) }
  when 'any'
    price_rules.any? { |rule| rule.applicable?(context) }
  else
    false
  end
end