spree_core 6.0.0.beta2 → 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 (132) 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 +7 -2
  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/customer_methods.rb +17 -1
  7. data/app/models/concerns/spree/has_number.rb +3 -2
  8. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  9. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  10. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  11. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  12. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  13. data/app/models/spree/adjusters/promotion.rb +1 -1
  14. data/app/models/spree/authentication/lockout.rb +34 -0
  15. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  16. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  17. data/app/models/spree/claim.rb +1 -0
  18. data/app/models/spree/commission_line.rb +4 -2
  19. data/app/models/spree/commission_rule.rb +1 -1
  20. data/app/models/spree/coupon_code.rb +14 -2
  21. data/app/models/spree/current.rb +15 -0
  22. data/app/models/spree/exchange.rb +1 -0
  23. data/app/models/spree/export.rb +8 -5
  24. data/app/models/spree/exports/customers.rb +2 -2
  25. data/app/models/spree/fulfillment_group.rb +127 -0
  26. data/app/models/spree/gift_card.rb +9 -0
  27. data/app/models/spree/invitation.rb +18 -5
  28. data/app/models/spree/number_generators/sequential.rb +2 -2
  29. data/app/models/spree/option_value.rb +7 -0
  30. data/app/models/spree/order.rb +35 -12
  31. data/app/models/spree/order_group.rb +78 -6
  32. data/app/models/spree/payment_source.rb +1 -1
  33. data/app/models/spree/payment_split.rb +24 -0
  34. data/app/models/spree/price.rb +74 -0
  35. data/app/models/spree/product.rb +4 -0
  36. data/app/models/spree/product_option_type.rb +11 -0
  37. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  38. data/app/models/spree/promotion/rules/category.rb +14 -9
  39. data/app/models/spree/promotion/rules/product.rb +17 -1
  40. data/app/models/spree/promotion.rb +1 -1
  41. data/app/models/spree/promotion_action.rb +12 -1
  42. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  43. data/app/models/spree/promotion_rule.rb +14 -0
  44. data/app/models/spree/return.rb +16 -6
  45. data/app/models/spree/return_line_item.rb +19 -0
  46. data/app/models/spree/search_provider/database.rb +10 -2
  47. data/app/models/spree/seller_payout.rb +3 -1
  48. data/app/models/spree/seller_transfer.rb +3 -1
  49. data/app/models/spree/store_credit.rb +4 -0
  50. data/app/models/spree/translations/batch.rb +4 -2
  51. data/app/models/spree/user_identity.rb +10 -6
  52. data/app/models/spree/variant.rb +8 -3
  53. data/app/models/spree/webhook_delivery.rb +28 -0
  54. data/app/models/spree/webhook_endpoint.rb +14 -0
  55. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  56. data/app/services/spree/carts/split_by_seller.rb +22 -12
  57. data/app/services/spree/companies/add_member.rb +8 -2
  58. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  59. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  60. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  61. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  62. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  63. data/app/services/spree/orders/update_statuses.rb +8 -2
  64. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  65. data/app/services/spree/sample_data/import_builder.rb +1 -1
  66. data/app/services/spree/sample_data/loader.rb +54 -40
  67. data/app/services/spree/seeds/all.rb +1 -26
  68. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  69. data/app/services/spree/seeds/api_keys.rb +10 -9
  70. data/app/services/spree/seeds/channels.rb +9 -8
  71. data/app/services/spree/seeds/commission_rates.rb +21 -20
  72. data/app/services/spree/seeds/customer_groups.rb +5 -4
  73. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  74. data/app/services/spree/seeds/payment_methods.rb +12 -11
  75. data/app/services/spree/seeds/product_types.rb +7 -8
  76. data/app/services/spree/seeds/returns_environment.rb +12 -11
  77. data/app/services/spree/seeds/roles.rb +8 -7
  78. data/app/services/spree/seeds/saved_reports.rb +19 -20
  79. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  80. data/app/services/spree/seeds/store_resources.rb +41 -0
  81. data/app/services/spree/seeds/store_scoped.rb +15 -0
  82. data/app/services/spree/seeds/tax_categories.rb +10 -9
  83. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  84. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  85. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  86. data/app/workflows/spree/carts/add_item.rb +5 -7
  87. data/app/workflows/spree/carts/complete.rb +20 -64
  88. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  89. data/app/workflows/spree/claims/create.rb +31 -1
  90. data/app/workflows/spree/claims/resolve.rb +7 -11
  91. data/app/workflows/spree/exchanges/create.rb +11 -0
  92. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  93. data/app/workflows/spree/orders/complete.rb +99 -2
  94. data/app/workflows/spree/price_lists/update.rb +17 -0
  95. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  96. data/app/workflows/spree/returns/create.rb +9 -15
  97. data/app/workflows/spree/returns/refund.rb +15 -24
  98. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  99. data/app/workflows/spree/sellers/invite.rb +1 -1
  100. data/config/locales/en.yml +7 -119
  101. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  102. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  103. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  104. data/db/sample_data/channels.rb +1 -1
  105. data/db/sample_data/collections.rb +1 -1
  106. data/db/sample_data/custom_field_definitions.rb +1 -1
  107. data/db/sample_data/digital_products.rb +1 -1
  108. data/db/sample_data/fulfillment.rb +1 -1
  109. data/db/sample_data/options.rb +4 -2
  110. data/db/sample_data/orders.rb +9 -10
  111. data/db/sample_data/payment_methods.rb +18 -18
  112. data/db/sample_data/product_type_categories.rb +1 -1
  113. data/db/sample_data/product_types.rb +2 -2
  114. data/db/sample_data/promotions.rb +2 -4
  115. data/db/sample_data/wholesale.rb +1 -1
  116. data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
  117. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
  118. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  119. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  120. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  121. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  122. data/lib/spree/core/engine.rb +8 -8
  123. data/lib/spree/core/version.rb +1 -1
  124. data/lib/spree/iso_data.rb +23 -4
  125. data/lib/spree/store_scope_guard.rb +16 -1
  126. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  127. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  128. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  129. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  130. data/lib/tasks/sample_data.rake +8 -2
  131. data/lib/tasks/upgrade.rake +6 -21
  132. metadata +13 -4
@@ -130,7 +130,7 @@ module Spree
130
130
  index.zero? ? order.bill_address&.company : nil,
131
131
  index.zero? ? order.bill_address&.city : nil,
132
132
  index.zero? ? order.bill_address&.postal_code : nil,
133
- index.zero? ? order.bill_address&.state_name : nil,
133
+ index.zero? ? order.bill_address&.state_text : nil,
134
134
  index.zero? ? order.bill_address&.country&.name : nil,
135
135
  index.zero? ? order.bill_address&.phone : nil,
136
136
  index.zero? ? order.ship_address&.full_name : nil,
@@ -139,7 +139,7 @@ module Spree
139
139
  index.zero? ? order.ship_address&.company : nil,
140
140
  index.zero? ? order.ship_address&.city : nil,
141
141
  index.zero? ? order.ship_address&.postal_code : nil,
142
- index.zero? ? order.ship_address&.state_name : nil,
142
+ index.zero? ? order.ship_address&.state_text : nil,
143
143
  index.zero? ? order.ship_address&.country&.name : nil,
144
144
  index.zero? ? order.ship_address&.phone : nil,
145
145
  index.zero? ? format_date(order.completed_at) : nil,
@@ -29,20 +29,26 @@ module Spree
29
29
  # blocklist would carry every future column onto siblings by default.
30
30
  # Adding a column here is a decision that it describes the checkout
31
31
  # rather than one seller's part of it.
32
+ #
33
+ # +metadata+ is here because at this point it holds what the checkout
34
+ # recorded about the purchase (copied from the cart), so every child gets
35
+ # the same hash — and so does the group. Once split, each order's
36
+ # metadata is its own: edits never propagate between siblings.
32
37
  CARRIED_TO_SIBLING = %w[
33
38
  email currency locale market_id channel_id company_id
34
39
  customer_id token accept_marketing preferred_stock_location_id
35
- customer_note last_ip_address po_number
40
+ customer_note last_ip_address po_number metadata
36
41
  ].freeze
37
42
 
38
- # @param cart [Spree::Cart]
39
43
  # @param order [Spree::Order] the paid draft order, adopted as the
40
44
  # group's first child rather than replaced — it is the record the
41
45
  # payment was taken against
42
46
  # @param partitions [Array<Spree::Carts::SellerPartition>] ordered, first
43
47
  # party first
48
+ # @param cart [Spree::Cart, nil] nil for an order raised from the admin,
49
+ # which never had one
44
50
  # @return [Spree::ServiceModule::Result] value is the Spree::OrderGroup
45
- def call(cart:, order:, partitions:)
51
+ def call(order:, partitions:, cart: nil)
46
52
  group = nil
47
53
  # Every apportionment here divides money in this one currency.
48
54
  @currency = order.currency
@@ -76,20 +82,24 @@ module Spree
76
82
  # keeps the reference they saw at checkout, and the children number off
77
83
  # it: R1001-1, R1001-2. The cart_id moves up too — the group is what that
78
84
  # cart completed into, and it becomes the replay anchor.
85
+ #
86
+ # The facts come off the order rather than the cart because the order
87
+ # carries every one of them and an admin-raised order has no cart; the
88
+ # cart is only linked, as the replay anchor, when there is one.
79
89
  def build_group(cart, order)
80
90
  order.update_columns(cart_id: nil)
81
91
 
82
- group = cart.store.order_groups.create!(
92
+ order.store.order_groups.create!(
83
93
  cart: cart,
84
- customer: cart.customer,
94
+ customer: order.customer,
85
95
  number: order.number,
86
- currency: cart.currency,
87
- email: cart.email,
88
- token: cart.token,
89
- ship_address: cart.ship_address&.snapshot,
90
- bill_address: cart.bill_address&.snapshot
96
+ currency: order.currency,
97
+ email: order.email,
98
+ token: order.token,
99
+ metadata: order.metadata.to_h.deep_dup,
100
+ ship_address: order.ship_address&.snapshot,
101
+ bill_address: order.bill_address&.snapshot
91
102
  )
92
- group
93
103
  end
94
104
 
95
105
  # Files the paid order under the group as its first child. Its own
@@ -109,7 +119,7 @@ module Spree
109
119
  # settled during completion.
110
120
  def hand_off(source_order, group, partition, index)
111
121
  sibling = source_order.store.orders.create!(
112
- source_order.attributes.slice(*CARRIED_TO_SIBLING).merge(
122
+ source_order.attributes.slice(*CARRIED_TO_SIBLING).deep_dup.merge(
113
123
  'order_group_id' => group.id,
114
124
  'seller_id' => partition.seller_id,
115
125
  'number' => "#{group.number}-#{index}",
@@ -18,11 +18,17 @@ module Spree
18
18
  # (memberships have no metadata column; a direct-membership payload is
19
19
  # applied by the caller through the `company_membership.form_fields`
20
20
  # contract, not stored here)
21
+ # @param require_acceptance [Boolean] always invite, even an existing
22
+ # customer — for callers who cannot vouch that the email is the
23
+ # person they mean (a storefront member typing an address). The
24
+ # emailed token is what proves the invitee owns the address and
25
+ # agrees to join; a direct membership would hand the company to
26
+ # whoever happens to hold that email.
21
27
  # @return [Spree::ServiceModule::Result] value is the created
22
28
  # Spree::CompanyMembership or Spree::CompanyInvitation
23
- def call(company:, email:, inviter: nil, metadata: nil)
29
+ def call(company:, email:, inviter: nil, metadata: nil, require_acceptance: false)
24
30
  normalized_email = email.to_s.strip.downcase
25
- customer = Spree.customer_class.find_by(email: normalized_email)
31
+ customer = Spree.customer_class.find_by(email: normalized_email) unless require_acceptance
26
32
 
27
33
  record =
28
34
  if customer
@@ -154,6 +154,12 @@ module Spree
154
154
  Spree.t(:price_list_import_too_many_breaks, count: Spree::Price::MAXIMUM_BREAKS_PER_VARIANT)
155
155
  elsif value[:invalid_quantities].present?
156
156
  Spree.t(:price_list_import_invalid_quantity, value: attributes['min_quantity'])
157
+ elsif (rising = value[:rising_ladders].presence)
158
+ # Named rung and floor, like the sibling branches: a file is written
159
+ # one rung per row, so the merchant needs to know which one and what
160
+ # it had to beat.
161
+ Spree.t(:price_list_import_price_rises_with_quantity,
162
+ quantity: rising.first[:min_quantity], floor: rising.first[:floor])
157
163
  else
158
164
  result.error.to_s.presence || Spree.t(:price_list_import_failed)
159
165
  end
@@ -37,7 +37,7 @@ module Spree
37
37
  private
38
38
 
39
39
  def product_scope
40
- Spree::Product.accessible_by(import.current_ability, :manage)
40
+ store.products.accessible_by(import.current_ability, :manage)
41
41
  end
42
42
  end
43
43
  end
@@ -125,15 +125,17 @@ module Spree
125
125
 
126
126
  # Which products this import may resolve an existing row onto.
127
127
  #
128
- # A seller's import reaches only that seller's own products. The
129
- # ability cannot answer this: it grants capability, never tenancy —
130
- # a seller holding `write_products` gets `can :manage, Spree::Product`,
131
- # the whole class (see Spree::Ability). On the API that is safe because
132
- # the controller scope-fetches through `current_seller`; a background
133
- # row processor has no controller, so the narrowing has to happen here
134
- # or a CSV naming another seller's slug would edit their product.
128
+ # The import's own store's products, and for a seller's import only
129
+ # that seller's. The ability cannot answer either: it grants
130
+ # capability, never tenancy — a seller holding `write_products` gets
131
+ # `can :manage, Spree::Product`, the whole class (see Spree::Ability),
132
+ # and an admin of two stores reaches both. On the API that is safe because
133
+ # the controller scope-fetches through `current_store` and
134
+ # `current_seller`; a background row processor has no controller, so
135
+ # the narrowing has to happen here or a CSV naming another store's or
136
+ # seller's slug would edit their product.
135
137
  def product_scope
136
- scope = Spree::Product.accessible_by(import.current_ability, :manage)
138
+ scope = store.products.accessible_by(import.current_ability, :manage)
137
139
  seller.present? ? scope.where(seller_id: seller.id) : scope
138
140
  end
139
141
 
@@ -237,7 +239,7 @@ module Spree
237
239
  def prepare_tax_category
238
240
  tax_category_name = attributes['tax_category'].strip
239
241
  cached_lookup(:tax_category, tax_category_name) do
240
- Spree::TaxCategory.find_by(name: tax_category_name)
242
+ store.tax_categories.find_by(name: tax_category_name)
241
243
  end
242
244
  end
243
245
 
@@ -302,15 +304,19 @@ module Spree
302
304
  def find_or_create_option_type!(label)
303
305
  cached_lookup(:option_type, label) do
304
306
  begin
305
- Spree::OptionType.search_by_name(label).first || Spree::OptionType.create!(label: label)
307
+ store_option_types.search_by_name(label).first || store_option_types.create!(label: label)
306
308
  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
307
309
  raise unless uniqueness_conflict?(e, :name)
308
310
 
309
- Spree::OptionType.search_by_name(label).first!
311
+ store_option_types.search_by_name(label).first!
310
312
  end
311
313
  end
312
314
  end
313
315
 
316
+ def store_option_types
317
+ Spree::OptionType.for_store(store)
318
+ end
319
+
314
320
  def find_or_create_option_value!(option_type, label)
315
321
  cached_lookup(:option_value, option_type.id, label) do
316
322
  begin
@@ -87,6 +87,9 @@ module Spree
87
87
  next if split.persisted?
88
88
 
89
89
  share = Spree::Money::Rounding.from_minor_units(shares[index], currency)
90
+ # So re-summing its payment total on save reuses this instance rather
91
+ # than loading a second copy of the row per share.
92
+ split.order = order
90
93
  split.currency = payment.currency
91
94
  split.authorized_amount = share
92
95
  split.captured_amount = captured ? share : 0
@@ -9,10 +9,16 @@ module Spree
9
9
  #
10
10
  # No-op when the order has no shipping address, no line items, or does
11
11
  # not require delivery (digital orders, etc.).
12
+ #
13
+ # Also a no-op once the order is placed. Its fulfillments hold the stock
14
+ # promised at placement, and an edit adjusts them in place through
15
+ # Spree::OrderInventory. Rebuilding would delete them and leave that
16
+ # promise on rows nothing can cancel or ship, so the stock stays held.
12
17
  class BuildFulfillments
13
18
  prepend Spree::ServiceModule::Base
14
19
 
15
20
  def call(order:)
21
+ return success(order) if order.completed?
16
22
  return success(order) unless order.ship_address_id.present?
17
23
  return success(order) unless order.line_items.any?
18
24
  return success(order) unless order.delivery_step_required?
@@ -60,14 +60,20 @@ module Spree
60
60
  # shipped and been captured while another has not, no proportion of the
61
61
  # group's totals can describe either.
62
62
  #
63
+ # Refunded spans both ledgers. A refund paid as store credit writes no
64
+ # {Spree::Refund} row, and counting only those rows left an order that
65
+ # had given every penny back still reading `paid`.
66
+ #
63
67
  # @return [Array(BigDecimal, BigDecimal, BigDecimal)] captured,
64
68
  # authorized and refunded, in that order
65
69
  def money_for(order)
70
+ credited = order.store_credit_refunds.sum(:amount)
71
+
66
72
  unless order.order_group_id.present?
67
73
  return [
68
74
  order.payments.valid.completed.sum(:amount),
69
75
  order.payments.valid.pending.sum(:amount),
70
- order.refunds.sum(:amount)
76
+ order.refunds.sum(:amount) + credited
71
77
  ]
72
78
  end
73
79
 
@@ -76,7 +82,7 @@ module Spree
76
82
 
77
83
  # Authorized means still to draw: what the shares allow, less what has
78
84
  # already been taken against them.
79
- [captured, splits.sum(&:authorized_amount) - captured, splits.sum(&:refunded_amount)]
85
+ [captured, splits.sum(&:authorized_amount) - captured, splits.sum(&:refunded_amount) + credited]
80
86
  end
81
87
 
82
88
  # Rolls the fulfillments up into one word for filtering and display.
@@ -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.