spree_core 6.0.0.beta1 → 6.0.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
  3. data/app/jobs/spree/sample_data/load_job.rb +18 -0
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
  5. data/app/mailers/spree/admin_user_mailer.rb +2 -2
  6. data/app/models/concerns/spree/acted_by.rb +210 -0
  7. data/app/models/concerns/spree/actor.rb +51 -0
  8. data/app/models/concerns/spree/admin_user_methods.rb +48 -17
  9. data/app/models/concerns/spree/customer_methods.rb +17 -1
  10. data/app/models/concerns/spree/has_number.rb +3 -2
  11. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  13. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  14. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  16. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  17. data/app/models/spree/adjusters/promotion.rb +1 -1
  18. data/app/models/spree/api_key.rb +5 -3
  19. data/app/models/spree/authentication/lockout.rb +34 -0
  20. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  21. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  22. data/app/models/spree/catalog.rb +50 -13
  23. data/app/models/spree/claim.rb +3 -1
  24. data/app/models/spree/commission_line.rb +4 -2
  25. data/app/models/spree/commission_rule.rb +1 -1
  26. data/app/models/spree/coupon_code.rb +14 -2
  27. data/app/models/spree/current.rb +56 -13
  28. data/app/models/spree/exchange.rb +3 -1
  29. data/app/models/spree/export.rb +8 -5
  30. data/app/models/spree/exports/customers.rb +2 -2
  31. data/app/models/spree/fulfillment_group.rb +127 -0
  32. data/app/models/spree/gift_card.rb +9 -0
  33. data/app/models/spree/invitation.rb +18 -5
  34. data/app/models/spree/line_item.rb +1 -1
  35. data/app/models/spree/number_generators/sequential.rb +2 -2
  36. data/app/models/spree/option_value.rb +7 -0
  37. data/app/models/spree/order.rb +89 -18
  38. data/app/models/spree/order_group.rb +78 -6
  39. data/app/models/spree/payment_source.rb +1 -1
  40. data/app/models/spree/payment_split.rb +24 -0
  41. data/app/models/spree/price.rb +74 -0
  42. data/app/models/spree/pricing_provider/internal.rb +37 -15
  43. data/app/models/spree/product.rb +4 -0
  44. data/app/models/spree/product_option_type.rb +11 -0
  45. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  46. data/app/models/spree/promotion/rules/category.rb +14 -9
  47. data/app/models/spree/promotion/rules/product.rb +17 -1
  48. data/app/models/spree/promotion.rb +1 -1
  49. data/app/models/spree/promotion_action.rb +12 -1
  50. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  51. data/app/models/spree/promotion_rule.rb +14 -0
  52. data/app/models/spree/refund.rb +2 -1
  53. data/app/models/spree/return.rb +18 -7
  54. data/app/models/spree/return_line_item.rb +19 -0
  55. data/app/models/spree/search_provider/database.rb +10 -2
  56. data/app/models/spree/seller_payout.rb +3 -1
  57. data/app/models/spree/seller_transfer.rb +3 -1
  58. data/app/models/spree/stock_receipt.rb +7 -2
  59. data/app/models/spree/store.rb +4 -0
  60. data/app/models/spree/store_credit.rb +4 -0
  61. data/app/models/spree/translations/batch.rb +4 -2
  62. data/app/models/spree/user_identity.rb +10 -6
  63. data/app/models/spree/variant.rb +8 -3
  64. data/app/models/spree/webhook_delivery.rb +28 -0
  65. data/app/models/spree/webhook_endpoint.rb +14 -0
  66. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  67. data/app/services/spree/carts/split_by_seller.rb +22 -12
  68. data/app/services/spree/companies/add_member.rb +8 -2
  69. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  70. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  71. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  72. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  73. data/app/services/spree/orders/approve.rb +3 -2
  74. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  75. data/app/services/spree/orders/update_statuses.rb +8 -2
  76. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  77. data/app/services/spree/sample_data/import_builder.rb +1 -1
  78. data/app/services/spree/sample_data/loader.rb +54 -40
  79. data/app/services/spree/seeds/all.rb +1 -26
  80. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  81. data/app/services/spree/seeds/api_keys.rb +10 -9
  82. data/app/services/spree/seeds/channels.rb +9 -8
  83. data/app/services/spree/seeds/commission_rates.rb +21 -20
  84. data/app/services/spree/seeds/customer_groups.rb +5 -4
  85. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  86. data/app/services/spree/seeds/payment_methods.rb +12 -11
  87. data/app/services/spree/seeds/product_types.rb +7 -8
  88. data/app/services/spree/seeds/returns_environment.rb +12 -11
  89. data/app/services/spree/seeds/roles.rb +8 -7
  90. data/app/services/spree/seeds/saved_reports.rb +19 -20
  91. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  92. data/app/services/spree/seeds/store_resources.rb +41 -0
  93. data/app/services/spree/seeds/store_scoped.rb +15 -0
  94. data/app/services/spree/seeds/tax_categories.rb +10 -9
  95. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  96. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  97. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  98. data/app/workflows/spree/carts/add_item.rb +5 -7
  99. data/app/workflows/spree/carts/complete.rb +20 -64
  100. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  101. data/app/workflows/spree/claims/create.rb +31 -1
  102. data/app/workflows/spree/claims/resolve.rb +7 -11
  103. data/app/workflows/spree/exchanges/create.rb +11 -0
  104. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  105. data/app/workflows/spree/orders/cancel.rb +3 -2
  106. data/app/workflows/spree/orders/complete.rb +99 -2
  107. data/app/workflows/spree/price_lists/update.rb +17 -0
  108. data/app/workflows/spree/refunds/create.rb +1 -1
  109. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  110. data/app/workflows/spree/returns/approve.rb +1 -1
  111. data/app/workflows/spree/returns/create.rb +11 -17
  112. data/app/workflows/spree/returns/refund.rb +16 -25
  113. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  114. data/app/workflows/spree/sellers/invite.rb +1 -1
  115. data/config/locales/en.yml +8 -119
  116. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  117. data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -0
  118. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  119. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  120. data/db/sample_data/channels.rb +1 -1
  121. data/db/sample_data/collections.rb +1 -1
  122. data/db/sample_data/custom_field_definitions.rb +1 -1
  123. data/db/sample_data/digital_products.rb +1 -1
  124. data/db/sample_data/fulfillment.rb +1 -1
  125. data/db/sample_data/options.rb +4 -2
  126. data/db/sample_data/orders.rb +9 -10
  127. data/db/sample_data/payment_methods.rb +18 -18
  128. data/db/sample_data/product_type_categories.rb +1 -1
  129. data/db/sample_data/product_types.rb +2 -2
  130. data/db/sample_data/promotions.rb +2 -4
  131. data/db/sample_data/wholesale.rb +1 -1
  132. data/lib/generators/spree/api_resource/api_resource_generator.rb +121 -6
  133. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
  134. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
  135. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  136. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  137. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +4 -1
  138. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  139. data/lib/generators/spree/model/model_generator.rb +40 -1
  140. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
  141. data/lib/generators/spree/model/templates/model.rb.tt +10 -4
  142. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  143. data/lib/spree/core/configuration.rb +25 -25
  144. data/lib/spree/core/engine.rb +35 -8
  145. data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
  146. data/lib/spree/core/version.rb +1 -1
  147. data/lib/spree/core.rb +27 -0
  148. data/lib/spree/iso_data.rb +23 -4
  149. data/lib/spree/store_scope_guard.rb +16 -1
  150. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  151. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  152. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  153. data/lib/spree/testing_support/factories/user_factory.rb +0 -1
  154. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  155. data/lib/tasks/backfill_actor_types.rake +35 -0
  156. data/lib/tasks/sample_data.rake +8 -2
  157. data/lib/tasks/store_settings.rake +1 -0
  158. data/lib/tasks/upgrade.rake +6 -21
  159. metadata +23 -9
  160. /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
  161. /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
  162. /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
  163. /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
  164. /data/{app/models → lib}/spree/checkout/step.rb +0 -0
@@ -35,11 +35,13 @@ module Spree
35
35
  # @return [Spree::ServiceModule::Result] success carries
36
36
  # `{ price_count: N }` — the number of rows passed to `upsert_all`.
37
37
  # Fails with the offending rows when a batch carries an unusable
38
- # `min_quantity`, or would take a variant past
39
- # {Spree::Price::MAXIMUM_BREAKS_PER_VARIANT} breaks on one list. This
40
- # path writes in SQL and runs no model validations, so both checks
41
- # live here rather than in each of the three controllers that reach
42
- # it (docs/plans/6.0-volume-pricing.md).
38
+ # `min_quantity`, puts a quantity on a base price, names one rung twice,
39
+ # would take a variant past
40
+ # {Spree::Price::MAXIMUM_BREAKS_PER_VARIANT} breaks on one list, or
41
+ # would leave a ladder charging more for a bigger order. This path
42
+ # writes in SQL and runs no model validations, so the checks live here
43
+ # rather than in each of the three writers that reach it
44
+ # (docs/plans/6.0-volume-pricing.md).
43
45
  def call(rows:)
44
46
  rows = Array(rows).map { |r| r.with_indifferent_access }
45
47
  # Amounts are parsed once, here; everything below reads numbers.
@@ -54,20 +56,35 @@ module Spree
54
56
  negative = rows_with_negative_amount(keyed)
55
57
  return failure(nil, invalid_amounts: negative) if negative.any?
56
58
 
57
- # PG rejects an upsert with two rows hitting the same unique key in one
58
- # statement ("ON CONFLICT DO UPDATE command cannot affect row a second
59
- # time"). Last-write-wins: keep the last occurrence of each key.
60
- deduped = keyed.reverse.uniq { |r| row_key(r) }.reverse
61
- upsert_rows, clear_rows = deduped.partition { |r| r[:amount].present? }
59
+ # A base price is one row per (variant, currency) whatever quantity it
60
+ # names, so a quantity here would upsert onto a key it does not
61
+ # describe. The model refuses it in `break_requires_price_list`, which
62
+ # this SQL path never runs.
63
+ misplaced = base_rows_with_quantity(keyed)
64
+ return failure(nil, quantities_on_base_prices: misplaced) if misplaced.any?
65
+
66
+ duplicates = rows_with_duplicate_quantity(keyed)
67
+ return failure(nil, duplicate_quantities: duplicates) if duplicates.any?
68
+
69
+ upsert_rows, clear_rows = keyed.partition { |r| r[:amount].present? }
70
+ # A clear at a quantity the batch also prices is a rung moving onto
71
+ # it, so the amount wins.
72
+ claimed = upsert_rows.map { |r| row_key(r) }.to_set
73
+ clear_rows = clear_rows.reject { |r| claimed.include?(row_key(r)) }
62
74
 
63
75
  payload = build_payload(upsert_rows)
64
- affected_keys = deduped.map { |r| row_key(r) }
76
+ affected_keys = (upsert_rows + clear_rows).map { |r| row_key(r) }
65
77
 
66
78
  return success(price_count: 0) if affected_keys.empty?
67
79
 
68
- over_cap = ladders_over_cap(upsert_rows, clear_rows)
80
+ stored = stored_rungs(upsert_rows + clear_rows)
81
+
82
+ over_cap = ladders_over_cap(upsert_rows, clear_rows, stored)
69
83
  return failure(nil, over_cap: over_cap) if over_cap.any?
70
84
 
85
+ rising = rising_ladders(upsert_rows, clear_rows, stored)
86
+ return failure(nil, rising_ladders: rising) if rising.any?
87
+
71
88
  base_rows, override_rows = payload.partition { |r| r[:price_list_id].nil? }
72
89
 
73
90
  Spree::Price.transaction do
@@ -129,7 +146,7 @@ module Spree
129
146
  # quantity address the bottom rung, which is every row written before
130
147
  # breaks existed (docs/plans/6.0-volume-pricing.md).
131
148
  def row_key(row)
132
- [row[:variant_id], row[:currency], row[:price_list_id], quantity_of(row)]
149
+ [row[:variant_id].to_s, row[:currency], row[:price_list_id].presence&.to_s, quantity_of(row)]
133
150
  end
134
151
 
135
152
  # Absent means the ladder's bottom rung. Anything present must be a
@@ -182,55 +199,177 @@ module Spree
182
199
  # constant is named: breaks *above* the bottom rung, so a variant priced
183
200
  # at one figure plus ten breaks is exactly at the limit.
184
201
  #
185
- # Only ladders the batch actually adds a break to are checked, so an
186
- # ordinary spreadsheet save — one quantity-1 row per variant — costs no
187
- # extra queries at all.
188
- #
189
202
  # @param rows [Array<Hash>] the rows carrying an amount
190
203
  # @param cleared_rows [Array<Hash>] the rows whose blank amount removes a rung
204
+ # @param stored [Hash] `{ ladder_key => { quantity => amount } }` as stored
191
205
  # @return [Array<Hash>] `[{ variant_id:, currency:, price_list_id: }, ...]`
192
- def ladders_over_cap(rows, cleared_rows = [])
193
- touched = rows.
194
- reject { |row| row[:price_list_id].blank? || quantity_of(row) == 1 }.
195
- group_by { |row| [row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s] }.
196
- transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
206
+ def ladders_over_cap(rows, cleared_rows, stored)
207
+ touched = breaks_by_ladder(rows)
197
208
  return [] if touched.empty?
198
209
 
199
- cleared = cleared_rows.
200
- reject { |row| row[:price_list_id].blank? || quantity_of(row) == 1 }.
201
- group_by { |row| [row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s] }.
202
- transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
203
-
204
- # One query for every ladder in the batch rather than one each: an
205
- # import writing a ladder across two hundred variants would otherwise
206
- # issue two hundred round trips inside the request. Cross-pairs the
207
- # `IN` clauses pull in are dropped by the lookup below, the same way
208
- # #sweep handles them.
209
- # Placeholder rows charge nothing, so they do not fill a ladder — the
210
- # model's own guard counts the same way, and a cap two writers
211
- # disagree about is one that refuses what it just allowed.
212
- stored = Spree::Price.
213
- where(variant_id: touched.keys.map { |key| key[0] },
214
- currency: touched.keys.map { |key| key[1] },
215
- price_list_id: touched.keys.map { |key| key[2] }).
216
- breaks.
217
- where.not(amount: nil).
218
- pluck(:variant_id, :currency, :price_list_id, :min_quantity).
219
- group_by { |variant_id, currency, price_list_id, _| [variant_id.to_s, currency, price_list_id.to_s] }
210
+ cleared = breaks_by_ladder(cleared_rows)
220
211
 
221
212
  touched.filter_map do |key, incoming|
222
213
  # Stored rungs this batch does not name are the ones that survive it;
223
214
  # counting the rest as well would refuse a merchant who deleted two
224
215
  # rungs and added two others, whose ladder ends the size it began.
225
- # The deletions ride in `clear_rows` and the sweep applies them, so
226
- # what is left after this batch is (survivors + incoming).
227
- survivors = stored.fetch(key, []).map(&:last).to_set - cleared.fetch(key, Set.new)
216
+ survivors = stored.fetch(key, {}).keys.reject { |quantity| quantity == 1 }.to_set - cleared.fetch(key, Set.new)
228
217
  next if (survivors | incoming).size <= Spree::Price::MAXIMUM_BREAKS_PER_VARIANT
229
218
 
230
219
  { variant_id: key[0], currency: key[1], price_list_id: key[2] }
231
220
  end
232
221
  end
233
222
 
223
+ # The break quantities each ladder in these rows carries.
224
+ def breaks_by_ladder(rows)
225
+ rows.
226
+ reject { |row| row[:price_list_id].blank? || quantity_of(row) == 1 }.
227
+ group_by { |row| ladder_key(row) }.
228
+ transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
229
+ end
230
+
231
+ # Base-price rows naming a quantity. A base price is one row per
232
+ # (variant, currency), so the quantity describes nothing and the row
233
+ # would upsert onto a key it does not match.
234
+ #
235
+ # @return [Array<Hash>] `[{ index: }, ...]`
236
+ def base_rows_with_quantity(rows)
237
+ rows.each_with_index.filter_map do |row, index|
238
+ { index: index } if row[:price_list_id].blank? && quantity_of(row) != 1
239
+ end
240
+ end
241
+
242
+ # Rows repeating a rung the batch has already priced. Only amounts
243
+ # collide: a clear at the same key is a rung moving onto it, which #call
244
+ # resolves in favour of the amount.
245
+ #
246
+ # @param rows [Array<Hash>]
247
+ # @return [Array<Hash>] `[{ index:, min_quantity: }, ...]`
248
+ def rows_with_duplicate_quantity(rows)
249
+ seen = Set.new
250
+
251
+ rows.each_with_index.filter_map do |row, index|
252
+ next if row[:amount].blank?
253
+
254
+ { index: index, min_quantity: quantity_of(row) } unless seen.add?(row_key(row))
255
+ end
256
+ end
257
+
258
+ # Ladders this batch would leave charging more for a bigger order. A rung
259
+ # must not cost more than the rung beneath it, or than the base price for
260
+ # a ladder starting above one unit; a bottom rung above base is a dearer
261
+ # agreement, which is allowed. Judged on the state the batch leaves, not
262
+ # on what it changed: no write path can produce a rising ladder, so one
263
+ # found rising came from outside them and must be repaired rather than
264
+ # built on. Base is the floor even where another list would undercut it
265
+ # below the threshold — the write path cannot know which list wins for
266
+ # which buyer (docs/plans/6.0-volume-pricing.md).
267
+ #
268
+ # @param rows [Array<Hash>] the rows carrying an amount
269
+ # @param cleared_rows [Array<Hash>] the rows whose blank amount removes a rung
270
+ # @param stored [Hash] `{ ladder_key => { quantity => amount } }` as stored
271
+ # @return [Array<Hash>] the offending rung and the price it must not exceed
272
+ def rising_ladders(rows, cleared_rows, stored)
273
+ keys = ladder_keys_to_check(rows + cleared_rows, stored)
274
+ return [] if keys.empty?
275
+
276
+ incoming = rungs_by_ladder(rows)
277
+ cleared = breaks_and_bottom_by_ladder(cleared_rows)
278
+ floors = base_floors(rows, cleared_rows, keys)
279
+
280
+ keys.filter_map do |key|
281
+ dropped = cleared.fetch(key, Set.new)
282
+ now = stored.fetch(key, {}).reject { |quantity, _| dropped.include?(quantity) }.
283
+ merge(incoming.fetch(key, {}))
284
+
285
+ breach = Spree::Price.rising_rung(now.sort_by(&:first), floors[key.first(2)])
286
+ next if breach.nil?
287
+
288
+ { variant_id: key[0], currency: key[1], price_list_id: key[2],
289
+ min_quantity: breach[0], amount: breach[1], floor: breach[2] }
290
+ end
291
+ end
292
+
293
+ # The ladders whose shape or floor this batch could change: those it
294
+ # writes rows on, plus every stored ladder of a variant whose base price
295
+ # it moves — lowering the shop price under a break raises the bill at the
296
+ # threshold just as surely as mispricing the break.
297
+ def ladder_keys_to_check(rows, stored)
298
+ own = rows.reject { |row| row[:price_list_id].blank? }.map { |row| ladder_key(row) }
299
+ rebased = rows.select { |row| row[:price_list_id].blank? }.
300
+ map { |row| [row[:variant_id].to_s, row[:currency]] }.to_set
301
+ return own.uniq if rebased.empty?
302
+
303
+ (own + stored.keys.select { |key| rebased.include?(key.first(2)) }).uniq
304
+ end
305
+
306
+ # `{ ladder_key => { quantity => amount } }` for these rows.
307
+ def rungs_by_ladder(rows)
308
+ rows.
309
+ reject { |row| row[:price_list_id].blank? }.
310
+ group_by { |row| ladder_key(row) }.
311
+ transform_values { |group| group.to_h { |row| [quantity_of(row), row[:amount]] } }
312
+ end
313
+
314
+ # Every quantity these rows remove, bottom rung included — unlike the cap
315
+ # check, which only counts breaks.
316
+ def breaks_and_bottom_by_ladder(rows)
317
+ rows.
318
+ reject { |row| row[:price_list_id].blank? }.
319
+ group_by { |row| ladder_key(row) }.
320
+ transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
321
+ end
322
+
323
+ # The stored rungs of every ladder this batch touches, and of every
324
+ # ladder whose base price it moves. Placeholder rows charge nothing, so a
325
+ # ladder whose quantity-1 row is a placeholder starts at its first real
326
+ # break. Cross-pairs the `IN` clauses pull in are dropped by the lookup,
327
+ # as in #sweep.
328
+ #
329
+ # @param rows [Array<Hash>]
330
+ # @return [Hash] `{ ladder_key => { quantity => amount } }`
331
+ def stored_rungs(rows)
332
+ variant_ids = rows.map { |row| row[:variant_id] }.uniq
333
+ currencies = rows.map { |row| row[:currency] }.uniq
334
+ return {} if variant_ids.empty?
335
+
336
+ Spree::Price.
337
+ where(variant_id: variant_ids, currency: currencies).
338
+ where.not(price_list_id: nil).
339
+ where.not(amount: nil).
340
+ pluck(:variant_id, :currency, :price_list_id, :min_quantity, :amount).
341
+ group_by { |variant_id, currency, price_list_id, _, _| [variant_id.to_s, currency, price_list_id.to_s] }.
342
+ transform_values { |rungs| rungs.to_h { |rung| [rung[3], rung[4]] } }
343
+ end
344
+
345
+ # The base price each ladder falls through to, as this batch would leave
346
+ # it, so a save that moves both the shop price and a break is judged on
347
+ # the pair it sent.
348
+ #
349
+ # @return [Hash] `{ [variant_id, currency] => amount }`
350
+ def base_floors(rows, cleared_rows, keys)
351
+ pairs = keys.map { |key| key.first(2) }.uniq
352
+ return {} if pairs.empty?
353
+
354
+ stored = Spree::Price.
355
+ where(variant_id: pairs.map(&:first), currency: pairs.map(&:last), price_list_id: nil).
356
+ where.not(amount: nil).
357
+ pluck(:variant_id, :currency, :amount).
358
+ to_h { |variant_id, currency, amount| [[variant_id.to_s, currency], amount] }
359
+
360
+ written = rows.select { |row| row[:price_list_id].blank? }.
361
+ to_h { |row| [[row[:variant_id].to_s, row[:currency]], row[:amount]] }
362
+ removed = cleared_rows.select { |row| row[:price_list_id].blank? }.
363
+ map { |row| [row[:variant_id].to_s, row[:currency]] }
364
+
365
+ stored.merge(written).except(*removed)
366
+ end
367
+
368
+ # A ladder is one variant's rungs, in one currency, on one list.
369
+ def ladder_key(row)
370
+ [row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s]
371
+ end
372
+
234
373
  # Parses locale-aware decimal input ("1.234,56" in DE, "1,234.56"
235
374
  # in en-US). Numeric values pass through; blank values become nil.
236
375
  def parse_amount(value)
@@ -254,7 +393,7 @@ module Spree
254
393
  .pluck(:id, :variant_id, :currency, :price_list_id, :min_quantity, :amount)
255
394
 
256
395
  doomed_ids = candidates.filter_map do |id, variant_id, currency, price_list_id, min_quantity, amount|
257
- key = [variant_id, currency, price_list_id, min_quantity]
396
+ key = [variant_id.to_s, currency, price_list_id&.to_s, min_quantity]
258
397
  next unless affected_set.include?(key)
259
398
  next id if amount.nil?
260
399
  next id if cleared_keys.include?(key)
@@ -7,7 +7,7 @@ module Spree
7
7
  # runs — a price-list import's `preferred_price_list_id`, say
8
8
  # @return [Spree::Import] persisted, unprocessed
9
9
  def self.call(csv_path:, import_class:, store: nil, user: nil, inline: false, skip_events: false, attributes: {})
10
- store ||= Spree::Store.default
10
+ store ||= Spree::Current.store
11
11
  # An admin of the target store, not `admin_user_class.first` — in a
12
12
  # multi-store or multi-tenant app that global first can belong to a
13
13
  # different store entirely.
@@ -4,65 +4,79 @@ module Spree
4
4
  prepend Spree::ServiceModule::Base
5
5
  include Spree::SampleData::Helper
6
6
 
7
- def call
7
+ # Loads the demo data into one store. The sample files and the imports
8
+ # they run read `Spree::Current.store`, so it is set for the whole run
9
+ # and restored afterwards.
10
+ #
11
+ # @param store [Spree::Store, nil] the store to load into; defaults to
12
+ # the default store, resolved after the seeds so a bare install works
13
+ # @return [Spree::ServiceModule::Result]
14
+ def call(store: nil)
8
15
  Spree::Events.disable do
9
16
  without_geocoding do
10
17
  ActiveRecord::Base.no_touching do
11
18
  puts 'Running seeds first...' unless seeds_loaded?
12
19
  ensure_seeds_loaded
13
20
 
14
- puts 'Loading sample configuration data...'
15
- load_configuration_data
21
+ @store = store || Spree::Store.default
22
+ Spree::Current.set(store: @store) { load_sample_data }
23
+ end
24
+ end
25
+ end
26
+ end
16
27
 
17
- puts 'Loading sample channels...'
18
- load_ruby_file('channels')
28
+ private
19
29
 
20
- puts 'Loading sample custom_field definitions...'
21
- load_ruby_file('custom_field_definitions')
30
+ attr_reader :store
22
31
 
23
- puts 'Loading sample options...'
24
- load_ruby_file('options')
32
+ def load_sample_data
33
+ puts 'Loading sample configuration data...'
34
+ load_configuration_data
25
35
 
26
- puts 'Loading sample product types...'
27
- load_ruby_file('product_types')
36
+ puts 'Loading sample channels...'
37
+ load_ruby_file('channels')
28
38
 
29
- puts 'Loading sample products...'
30
- load_products
39
+ puts 'Loading sample custom_field definitions...'
40
+ load_ruby_file('custom_field_definitions')
31
41
 
32
- puts 'Attaching the sample digital product file...'
33
- load_ruby_file('digital_products')
42
+ puts 'Loading sample options...'
43
+ load_ruby_file('options')
34
44
 
35
- puts 'Publishing sample products on the default channel...'
36
- publish_sample_products(Spree::Store.default)
45
+ puts 'Loading sample product types...'
46
+ load_ruby_file('product_types')
37
47
 
38
- puts 'Loading sample product translations...'
39
- load_product_translations
48
+ puts 'Loading sample products...'
49
+ load_products
40
50
 
41
- puts 'Linking product types to categories...'
42
- load_ruby_file('product_type_categories')
51
+ puts 'Attaching the sample digital product file...'
52
+ load_ruby_file('digital_products')
43
53
 
44
- puts 'Loading sample collections...'
45
- load_ruby_file('collections')
54
+ puts 'Publishing sample products on every channel...'
55
+ publish_sample_products(store)
46
56
 
47
- puts 'Loading sample customers...'
48
- load_customers
57
+ puts 'Loading sample product translations...'
58
+ load_product_translations
49
59
 
50
- puts 'Loading wholesale demo data...'
51
- load_ruby_file('wholesale')
60
+ puts 'Linking product types to categories...'
61
+ load_ruby_file('product_type_categories')
52
62
 
53
- puts 'Loading sample orders...'
54
- load_ruby_file('orders')
63
+ puts 'Loading sample collections...'
64
+ load_ruby_file('collections')
55
65
 
56
- puts 'Loading sample posts...'
57
- load_ruby_file('posts')
66
+ puts 'Loading sample customers...'
67
+ load_customers
58
68
 
59
- puts 'Sample data loaded successfully!'
60
- end
61
- end
62
- end
63
- end
69
+ puts 'Loading wholesale demo data...'
70
+ load_ruby_file('wholesale')
64
71
 
65
- private
72
+ puts 'Loading sample orders...'
73
+ load_ruby_file('orders')
74
+
75
+ puts 'Loading sample posts...'
76
+ load_ruby_file('posts')
77
+
78
+ puts 'Sample data loaded successfully!'
79
+ end
66
80
 
67
81
  def load_configuration_data
68
82
  load_ruby_file('fulfillment')
@@ -72,19 +86,19 @@ module Spree
72
86
 
73
87
  def load_products
74
88
  csv_path = sample_data_path.join('products.csv')
75
- Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Products, inline: true)
89
+ Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Products, store: store, inline: true)
76
90
  end
77
91
 
78
92
  def load_product_translations
79
93
  csv_path = sample_data_path.join('product_translations.csv')
80
94
  return unless csv_path.exist?
81
95
 
82
- Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::ProductTranslations, inline: true)
96
+ Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::ProductTranslations, store: store, inline: true)
83
97
  end
84
98
 
85
99
  def load_customers
86
100
  csv_path = sample_data_path.join('customers.csv')
87
- Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Customers, inline: true)
101
+ Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Customers, store: store, inline: true)
88
102
  end
89
103
  end
90
104
  end
@@ -10,39 +10,14 @@ module Spree
10
10
  # countries gem, and zones are migration-only, so none of them
11
11
  # are seeded.
12
12
 
13
- # additional data
14
- TaxCategories.call
15
-
16
- # store & channels
17
13
  Stores.call
18
- Channels.call
19
- # Roles are store-owned, so they can only be seeded once a store
20
- # exists — every store gets its own immutable admin role.
21
- Roles.call
22
- # Store-scoped, so it can only run once a store exists.
23
- DigitalDelivery.call
14
+ Spree::Store.find_each { |store| StoreResources.call(store: store) }
24
15
  # The warehouse, delivery zones and pickup are not seeded: their
25
16
  # shape depends on which country the shop sells from, and nobody
26
17
  # has answered that yet. Spree::Stores::ProvisionDefaults builds
27
18
  # them from the merchant's answer — at first-run setup, or here
28
19
  # when ADMIN_EMAIL/ADMIN_PASSWORD name an install that skips it.
29
20
  AdminUser.call
30
-
31
- # add store resources
32
- PaymentMethods.call
33
- TaxCategories.call
34
- ProductTypes.call
35
- CustomerGroups.call
36
- ReturnsEnvironment.call
37
- # The marketplace's catch-all rate, at the bottom of the list so
38
- # narrower rates added later resolve ahead of it.
39
- CommissionRates.call
40
- # What a new seller has to do before this marketplace will let
41
- # them trade. Runs after stores for the same reason roles do.
42
- SellerRequirements.call
43
- ApiKeys.call
44
- SavedReports.call
45
- AllowedOrigins.call
46
21
  end
47
22
  end
48
23
  end
@@ -2,11 +2,12 @@ module Spree
2
2
  module Seeds
3
3
  class AllowedOrigins
4
4
  prepend Spree::ServiceModule::Base
5
+ include StoreScoped
5
6
 
6
- def call
7
- Spree::Store.all.each do |store|
8
- store.allowed_origins.find_or_create_by!(origin: 'http://localhost')
9
- end
7
+ private
8
+
9
+ def seed(store)
10
+ store.allowed_origins.find_or_create_by!(origin: 'http://localhost')
10
11
  end
11
12
  end
12
13
  end
@@ -2,17 +2,18 @@ module Spree
2
2
  module Seeds
3
3
  class ApiKeys
4
4
  prepend Spree::ServiceModule::Base
5
+ include StoreScoped
5
6
 
6
- def call
7
- Spree::Store.find_each do |store|
8
- unless store.api_keys.active.publishable.where(channel_id: nil).exists?
9
- store.api_keys.create!(name: 'Default', key_type: 'publishable')
10
- end
7
+ private
11
8
 
12
- wholesale = store.channels.find_by(code: Channels::WHOLESALE_CODE)
13
- if wholesale && !store.api_keys.active.publishable.where(channel: wholesale).exists?
14
- store.api_keys.create!(name: 'Storefront (Wholesale)', key_type: 'publishable', channel: wholesale)
15
- end
9
+ def seed(store)
10
+ unless store.api_keys.active.publishable.where(channel_id: nil).exists?
11
+ store.api_keys.create!(name: 'Default', key_type: 'publishable')
12
+ end
13
+
14
+ wholesale = store.channels.find_by(code: Channels::WHOLESALE_CODE)
15
+ if wholesale && !store.api_keys.active.publishable.where(channel: wholesale).exists?
16
+ store.api_keys.create!(name: 'Storefront (Wholesale)', key_type: 'publishable', channel: wholesale)
16
17
  end
17
18
  end
18
19
  end
@@ -6,18 +6,19 @@ module Spree
6
6
  # See docs/plans/5.6-store-channel-context-and-key-binding.md.
7
7
  class Channels
8
8
  prepend Spree::ServiceModule::Base
9
+ include StoreScoped
9
10
 
10
11
  WHOLESALE_CODE = 'wholesale'.freeze
11
12
 
12
- def call
13
- Spree::Store.find_each do |store|
14
- store.ensure_default_channel
13
+ private
15
14
 
16
- store.channels.find_or_create_by!(code: WHOLESALE_CODE) do |channel|
17
- channel.name = 'Wholesale'
18
- channel.preferred_storefront_access = 'login_required'
19
- channel.preferred_guest_checkout = false
20
- end
15
+ def seed(store)
16
+ store.ensure_default_channel
17
+
18
+ store.channels.find_or_create_by!(code: WHOLESALE_CODE) do |channel|
19
+ channel.name = 'Wholesale'
20
+ channel.preferred_storefront_access = 'login_required'
21
+ channel.preferred_guest_checkout = false
21
22
  end
22
23
  end
23
24
  end
@@ -22,31 +22,32 @@ module Spree
22
22
  # setting the value is the operator's first commission decision.
23
23
  class CommissionRates
24
24
  prepend Spree::ServiceModule::Base
25
+ include StoreScoped
25
26
 
26
27
  DEFAULT_CODE = 'marketplace-default'.freeze
27
28
 
28
- def call
29
- Spree::Store.find_each do |store|
30
- # Live rows only, matching the partial unique index: an operator who
31
- # deleted the seeded fallback gets it back on the next run rather
32
- # than being left with no bottom-of-list catch-all forever.
33
- next if store.commission_rates.exists?(code: DEFAULT_CODE)
29
+ private
34
30
 
35
- rate = store.commission_rates.create!(
36
- name: Spree.t('seed.commission_rates.marketplace_default'),
37
- code: DEFAULT_CODE,
38
- kind: 'percentage',
39
- value: 0,
40
- enabled: false
41
- )
31
+ def seed(store)
32
+ # Live rows only, matching the partial unique index: an operator who
33
+ # deleted the seeded fallback gets it back on the next run rather
34
+ # than being left with no bottom-of-list catch-all forever.
35
+ return if store.commission_rates.exists?(code: DEFAULT_CODE)
42
36
 
43
- # New rates are born at the top, which is right for one an operator
44
- # adds and wrong for this one: it matches every sale, so anywhere but
45
- # the bottom leaves the rates below it unreachable. A store seeded
46
- # after it already has rates would otherwise have them all shadowed
47
- # the moment this was enabled.
48
- rate.move_to_bottom
49
- end
37
+ rate = store.commission_rates.create!(
38
+ name: Spree.t('seed.commission_rates.marketplace_default'),
39
+ code: DEFAULT_CODE,
40
+ kind: 'percentage',
41
+ value: 0,
42
+ enabled: false
43
+ )
44
+
45
+ # New rates are born at the top, which is right for one an operator
46
+ # adds and wrong for this one: it matches every sale, so anywhere but
47
+ # the bottom leaves the rates below it unreachable. A store seeded
48
+ # after it already has rates would otherwise have them all shadowed
49
+ # the moment this was enabled.
50
+ rate.move_to_bottom
50
51
  end
51
52
  end
52
53
  end
@@ -5,13 +5,14 @@ module Spree
5
5
  # wholesale price lists key off.
6
6
  class CustomerGroups
7
7
  prepend Spree::ServiceModule::Base
8
+ include StoreScoped
8
9
 
9
10
  WHOLESALE_NAME = 'Wholesale'.freeze
10
11
 
11
- def call
12
- Spree::Store.find_each do |store|
13
- store.customer_groups.find_or_create_by!(name: WHOLESALE_NAME)
14
- end
12
+ private
13
+
14
+ def seed(store)
15
+ store.customer_groups.find_or_create_by!(name: WHOLESALE_NAME)
15
16
  end
16
17
  end
17
18
  end