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
@@ -19,7 +19,33 @@ module Spree
19
19
 
20
20
  delegate :eligible?, to: :promotion
21
21
 
22
- # Adds a line item to the Order if the promotion is eligible
22
+ # @return [Symbol]
23
+ def discount_scope
24
+ :line_item
25
+ end
26
+
27
+ # The gift is rarely the product the rules name ("buy a coffee maker,
28
+ # get a free tote"), so the rules decide whether the promotion applies
29
+ # and this action decides which lines it pays for.
30
+ #
31
+ # @param order [Spree::Order, Spree::Cart]
32
+ # @param line_item [Spree::LineItem]
33
+ # @return [Boolean]
34
+ def applies_to_line_item?(order, line_item)
35
+ gifted_item?(line_item) && qualifies_beyond_the_gift?(order)
36
+ end
37
+
38
+ # Covers the gifted units of the line at their own price, leaving any
39
+ # further units of the same variant the shopper bought themselves to be
40
+ # paid for.
41
+ #
42
+ # @param line_item [Spree::LineItem]
43
+ # @return [BigDecimal] never positive
44
+ def compute_amount(line_item)
45
+ line_item.price * gifted_quantity_of(line_item) * -1
46
+ end
47
+
48
+ # Tops the order up to the promised quantity and discounts it.
23
49
  #
24
50
  # This doesn't play right with Add to Cart events because at the moment
25
51
  # the item was added to cart the promo may not be eligible. However it
@@ -32,46 +58,26 @@ module Spree
32
58
  # - Customer increases item quantity to 5 (order total goes to $50)
33
59
  # - Now the order is eligible for the promo and the action should perform
34
60
  #
35
- # Another complication is when the same line item created by the promo
36
- # is also added to cart on a separate action.
37
- #
38
- # e.g.
39
- # - Promo adds 1 item A to cart if order total greater then $30
40
- # - Customer add 2 items B to cart, current order total is $40
41
- # - This action performs adding item A to cart since order is eligible
42
- # - Customer changes his mind and updates item B quantity to 1
43
- # - At this point order is no longer eligible and one might expect
44
- # that item A should be removed
45
- #
46
- # It doesn't remove items from the order here because there's no way
47
- # it can know whether that item was added via this promo action or if
48
- # it was manually populated somewhere else. In that case the item
49
- # needs to be manually removed from the order by the customer
61
+ # A shopper who already has the gift variant is given theirs free rather
62
+ # than a duplicate, so the promotion still applies when it has nothing
63
+ # to add.
50
64
  def perform(options = {})
51
65
  order = options[:order]
52
66
  return unless eligible? order
67
+ return unless qualifies_beyond_the_gift?(order)
53
68
 
54
- action_taken = false
55
- promotion_action_line_items.each do |item|
56
- current_quantity = order.quantity_of(item.variant)
57
- next unless current_quantity < item.quantity && item_available?(item)
69
+ added = add_missing_line_items(order)
58
70
 
59
- add_service = order.is_a?(Spree::Cart) ? Spree.cart_add_item_workflow : Spree.order_add_item_service
60
- line_item = add_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order },
61
- variant: item.variant,
62
- quantity: item.quantity - current_quantity).value
63
- action_taken = true if line_item.try(:valid?)
64
- end
65
- action_taken
71
+ apply_via_adjuster(options) || added
66
72
  end
67
73
 
68
74
  # Called by promotion handler when a promotion is removed
69
75
  # This will find any line item matching the ones defined in the PromotionAction
70
76
  # and remove the same quantity as was added by the PromotionAction.
71
- # Should help to prevent some of cases listed above the #perform method
72
77
  def revert(options = {})
73
78
  order = options[:order]
74
79
  return if eligible?(order)
80
+ return unless order.promotions.include?(promotion)
75
81
 
76
82
  action_taken = false
77
83
  promotion_action_line_items.each do |item|
@@ -89,13 +95,69 @@ module Spree
89
95
  end
90
96
 
91
97
  # Checks that there's enough stock to add the line item to the order
92
- def item_available?(item)
98
+ #
99
+ # @param item [Spree::PromotionActionLineItem]
100
+ # @param quantity [Integer] units wanted, defaulting to the whole gift
101
+ # @return [Boolean]
102
+ def item_available?(item, quantity = item.quantity)
93
103
  quantifier = Spree::Stock::Quantifier.new(item.variant)
94
- quantifier.can_supply? item.quantity
104
+ quantifier.can_supply? quantity
95
105
  end
96
106
 
97
107
  private
98
108
 
109
+ # Whether this line holds a variant the promotion gives away.
110
+ def gifted_item?(line_item)
111
+ gifted_quantity_of(line_item).positive?
112
+ end
113
+
114
+ # A gift cannot be the thing that qualifies the order for the promotion
115
+ # giving it away: a rule naming the gift's own product would otherwise
116
+ # hand the item over to anyone who put it in their cart. The order has
117
+ # to hold a line the rules count with units this action is not already
118
+ # covering. A promotion with no rules qualifies on anything, so there is
119
+ # nothing for the gift to stand in for.
120
+ def qualifies_beyond_the_gift?(order)
121
+ return true if promotion.promotion_rules.empty?
122
+
123
+ order.line_items.any? do |line_item|
124
+ promotion.line_item_actionable?(order, line_item) &&
125
+ gifted_quantity_of(line_item) < line_item.quantity
126
+ end
127
+ end
128
+
129
+ # How many units of this line the promotion pays for — never more than
130
+ # the line holds, so a shopper buying three of a variant gifted once
131
+ # still pays for two. `quantity` is nullable and written by `upsert_all`,
132
+ # which skips validation, so a missing or negative one gifts nothing
133
+ # rather than raising or turning the discount into a surcharge.
134
+ #
135
+ # @param line_item [Spree::LineItem]
136
+ # @return [Integer]
137
+ def gifted_quantity_of(line_item)
138
+ gifted = promotion_action_line_items.detect { |item| item.variant_id == line_item.variant_id }
139
+ return 0 if gifted.nil?
140
+
141
+ [[line_item.quantity, gifted.quantity.to_i].min, 0].max
142
+ end
143
+
144
+ def add_missing_line_items(order)
145
+ added_results = promotion_action_line_items.map do |item|
146
+ missing = item.quantity.to_i - order.quantity_of(item.variant)
147
+ next false unless missing.positive? && item_available?(item, missing)
148
+
149
+ add_service = order.is_a?(Spree::Cart) ? Spree.cart_add_item_workflow : Spree.order_add_item_service
150
+ result = add_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order },
151
+ variant: item.variant,
152
+ quantity: missing)
153
+ result.success?
154
+ end
155
+
156
+ order.line_items.reload if added_results.any?
157
+
158
+ added_results.any?
159
+ end
160
+
99
161
  def promotion_variants_scope
100
162
  promotion.store.variants
101
163
  end
@@ -21,18 +21,16 @@ module Spree
21
21
  'category'
22
22
  end
23
23
 
24
- # Decode prefixed IDs before delegating to the generated setter (direct
25
- # calls bypass PrefixedId's assign_attributes auto-resolver).
26
- #
27
- # Resolved through the promotion's own store, so an id belonging to
28
- # another store raises rather than quietly linking — the promotion
29
- # would otherwise discount against a catalog its store cannot see.
24
+ # Resolved through the promotion's own store, prefixed and raw ids
25
+ # alike, so an id belonging to another store raises rather than
26
+ # quietly linking — the promotion would otherwise discount against a
27
+ # catalog its store cannot see.
30
28
  def category_ids=(ids)
31
- super(Array(ids).map do |id|
32
- Spree::PrefixedId.prefixed_id?(id) ? promotion_categories_scope.find_by_param!(id).id : id
33
- end)
29
+ super(ids_within_store(ids, promotion && promotion_categories_scope))
34
30
  end
35
31
 
32
+ validate :categories_belong_to_store
33
+
36
34
  #
37
35
  # Preferences
38
36
  #
@@ -140,6 +138,13 @@ module Spree
140
138
  promotion.store.categories
141
139
  end
142
140
 
141
+ def categories_belong_to_store
142
+ return if promotion.nil? || category_ids.empty?
143
+ return if (category_ids - promotion_categories_scope.where(id: category_ids).ids).empty?
144
+
145
+ errors.add(:categories, :invalid)
146
+ end
147
+
143
148
  # IDs of categories in rule including all their children
144
149
  def eligible_category_ids_including_children
145
150
  @eligible_category_ids_including_children ||= begin
@@ -15,6 +15,15 @@ module Spree
15
15
 
16
16
  self.additional_permitted_attributes = [product_ids: []]
17
17
 
18
+ validate :products_belong_to_store
19
+
20
+ # Resolved through the promotion's own store, so another store's
21
+ # product raises rather than being linked and then served back with
22
+ # its admin data.
23
+ def product_ids=(ids)
24
+ super(ids_within_store(ids, promotion&.store&.products))
25
+ end
26
+
18
27
  #
19
28
  # Preferences
20
29
  #
@@ -86,11 +95,18 @@ module Spree
86
95
  ActiveSupport::Deprecation.warn(
87
96
  'Please use `product_ids=` instead.'
88
97
  )
89
- self.product_ids = s
98
+ self.product_ids = s.to_s.split(',')
90
99
  end
91
100
 
92
101
  private
93
102
 
103
+ def products_belong_to_store
104
+ return if promotion.nil? || product_ids.empty?
105
+ return if (product_ids - promotion.store.products.where(id: product_ids).ids).empty?
106
+
107
+ errors.add(:products, :invalid)
108
+ end
109
+
94
110
  def add_products
95
111
  return if product_ids_to_add.nil?
96
112
 
@@ -317,7 +317,7 @@ module Spree
317
317
 
318
318
  def code_for_order(order)
319
319
  if multi_codes?
320
- coupon_codes.find_by(order: order)&.code
320
+ coupon_codes.held_by(order).first&.code
321
321
  else
322
322
  code
323
323
  end
@@ -37,6 +37,17 @@ module Spree
37
37
  false
38
38
  end
39
39
 
40
+ # Whether this action's line-level discount may land on +line_item+.
41
+ # Promotion rules narrow it by default; an action that creates its own
42
+ # lines answers for itself.
43
+ #
44
+ # @param order [Spree::Order, Spree::Cart]
45
+ # @param line_item [Spree::LineItem]
46
+ # @return [Boolean]
47
+ def applies_to_line_item?(order, line_item)
48
+ promotion.line_item_actionable?(order, line_item)
49
+ end
50
+
40
51
  # Returns true if the promotion action is a free shipping action
41
52
  #
42
53
  # @return [Boolean]
@@ -53,7 +64,7 @@ module Spree
53
64
  def self.types_for_discount_scope(scope)
54
65
  case scope
55
66
  when :line_item
56
- %w[Spree::Promotion::Actions::CreateItemAdjustments Spree::Promotion::Actions::CreateItemAdjustments]
67
+ %w[Spree::Promotion::Actions::CreateItemAdjustments Spree::Promotion::Actions::CreateLineItems]
57
68
  when :fulfillment
58
69
  %w[Spree::Promotion::Actions::FreeShipping Spree::Promotion::Actions::FreeShipping]
59
70
  when :order
@@ -80,8 +80,7 @@ module Spree
80
80
  clear_persisted_coupon_code(coupon_code)
81
81
 
82
82
  if promotion.multi_codes?
83
- coupon_code = promotion.coupon_codes.find_by(order: order)
84
- coupon_code&.remove_from_order
83
+ promotion.coupon_codes.held_by(order).first&.remove_from_order
85
84
  else
86
85
  promotion.touch
87
86
  end
@@ -217,7 +216,10 @@ module Spree
217
216
  end
218
217
 
219
218
  if discount || created_line_items
220
- handle_coupon_code(discount, coupon_code) if discount
219
+ if discount && !claim_coupon_code(discount, coupon_code)
220
+ release_coupon_code(order, coupon_code)
221
+ return set_error_code :coupon_code_used
222
+ end
221
223
 
222
224
  order.recalculate_totals!
223
225
  set_success_code :coupon_code_applied
@@ -236,8 +238,51 @@ module Spree
236
238
  end
237
239
  end
238
240
 
239
- def handle_coupon_code(discount, coupon_code)
240
- Spree::CouponCode.unused.find_by(promotion_id: discount.promotion_id, code: coupon_code)&.apply_order!(order)
241
+ # A single-use code is spent only when an order is placed with it, so a
242
+ # code sitting on another open cart is taken from it rather than
243
+ # refused: whoever presents it now gets the discount, and whoever places
244
+ # an order first keeps the code. A cart in the middle of checkout keeps
245
+ # it, and so does a draft order, since its totals are fixed and money
246
+ # may already be at the gateway.
247
+ #
248
+ # The holder is locked before the code, the same order checkout takes
249
+ # them in, so the two serialize instead of deadlocking.
250
+ #
251
+ # @return [Boolean] false when the code can no longer be claimed
252
+ def claim_coupon_code(discount, coupon_code)
253
+ record = Spree::CouponCode.find_by(promotion_id: discount.promotion_id, code: coupon_code)
254
+ return true if record.nil?
255
+
256
+ holder = record.holder
257
+ holder = nil if holder && same_record?(holder, order)
258
+
259
+ Spree::CouponCode.transaction do
260
+ holder&.lock!
261
+ record.lock!
262
+ next false if record.used? || !same_holder?(record.holder, holder) || !releasable?(holder)
263
+
264
+ release_coupon_code(holder, coupon_code) if holder
265
+ record.apply_order!(order)
266
+ end
267
+ end
268
+
269
+ # Gift cards are turned off so a holder paying with one keeps it.
270
+ def release_coupon_code(holder, coupon_code)
271
+ self.class.new(holder, enable_gift_cards: false).remove(coupon_code)
272
+ end
273
+
274
+ def releasable?(holder)
275
+ return true if holder.nil?
276
+
277
+ holder.is_a?(Spree::Cart) && holder.completed_at.nil? && !holder.completion_claimed?
278
+ end
279
+
280
+ def same_record?(holder, other)
281
+ holder.class == other.class && holder.id == other.id
282
+ end
283
+
284
+ def same_holder?(current, expected)
285
+ current.nil? || same_record?(current, expected || order)
241
286
  end
242
287
 
243
288
  # Whether the coupon handler should also handle gift card codes.
@@ -55,6 +55,20 @@ module Spree
55
55
 
56
56
  private
57
57
 
58
+ # Resolves ids, prefixed or raw, through the promotion's store, so another
59
+ # store's record raises instead of being linked. A rule built before its
60
+ # promotion is known passes the ids through; its validation checks them.
61
+ #
62
+ # @param ids [Array<String, Integer>]
63
+ # @param scope [ActiveRecord::Relation, nil] the store's records
64
+ # @return [Array<Integer>]
65
+ def ids_within_store(ids, scope)
66
+ ids = Array(ids).reject(&:blank?)
67
+ return ids if scope.nil?
68
+
69
+ ids.map { |id| Spree::PrefixedId.prefixed_id?(id) ? scope.find_by_param!(id).id : scope.find(id).id }
70
+ end
71
+
58
72
  def eligibility_error_message(key, options = {})
59
73
  Spree.t(key, Hash[scope: [:eligibility_errors, :messages]].merge(options))
60
74
  end
@@ -111,21 +111,31 @@ module Spree
111
111
  end
112
112
  end
113
113
 
114
+ # Whether the warehouse has reported on this return.
115
+ #
116
+ # @return [Boolean]
117
+ def counted?
118
+ received? || refunded?
119
+ end
120
+
114
121
  # What the customer is owed for the items being returned.
115
122
  def refund_total
116
- return_line_items.sum(&:pre_tax_amount)
123
+ return_line_items.sum(&:refund_amount)
117
124
  end
118
125
 
119
- # What a refund from this return paid for, line by line. Counts what the
120
- # warehouse received rather than what the customer announced, so it agrees
121
- # with the amount {Spree::Returns::Refund} works out for itself.
126
+ # What a refund from this return paid for, line by line. Only ever what
127
+ # the warehouse counted: the seller's clawback is built from this, and a
128
+ # line nobody counted earned nothing back.
122
129
  #
123
130
  # @return [Hash{Integer => BigDecimal}] line item id => amount
124
131
  def refunded_line_amounts
132
+ return {} unless counted?
133
+
125
134
  return_line_items.each_with_object(Hash.new(0)) do |line, amounts|
126
- next if line.quantity.to_i.zero? || line.received_quantity.to_i.zero?
135
+ amount = line.refund_amount
136
+ next if amount.zero?
127
137
 
128
- amounts[line.line_item_id] += (line.pre_tax_amount / line.quantity) * line.received_quantity.to_i
138
+ amounts[line.line_item_id] += amount
129
139
  end
130
140
  end
131
141
 
@@ -19,6 +19,25 @@ module Spree
19
19
 
20
20
  delegate :order, :currency, to: :return
21
21
 
22
+ # What this line refunds. Once the warehouse has counted, only the units
23
+ # that arrived are paid for — a customer who announced three and sent two
24
+ # is owed two — and before the count there is nothing to go on but the
25
+ # announced quantity.
26
+ #
27
+ # Rounded to the currency, because this is the ceiling a refund is checked
28
+ # against as well as the figure the dialog offers: a third of $29.99 taken
29
+ # three times is not $29.99 until it is.
30
+ #
31
+ # @return [BigDecimal]
32
+ def refund_amount
33
+ return pre_tax_amount unless self.return.counted?
34
+ return 0.to_d if quantity.to_i.zero?
35
+
36
+ Spree::Money::Rounding.to_currency(
37
+ (pre_tax_amount / quantity) * received_quantity.to_i, currency
38
+ )
39
+ end
40
+
22
41
  def display_pre_tax_amount
23
42
  Spree::Money.new(pre_tax_amount, currency: currency)
24
43
  end
@@ -7,6 +7,14 @@ module Spree
7
7
  'best_selling' => :by_best_selling
8
8
  }.freeze
9
9
 
10
+ # @param store [Spree::Store]
11
+ # @param audience [Symbol, nil] who filters: `:store` (shoppers) limits
12
+ # filters to what the storefront may query; `nil` is the back office
13
+ def initialize(store, audience: :store)
14
+ super(store)
15
+ @audience = audience
16
+ end
17
+
10
18
  def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
11
19
  filters = filters.is_a?(Hash) ? filters.dup : {}
12
20
  option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
@@ -72,7 +80,7 @@ module Spree
72
80
 
73
81
  ransack_filters = sanitize_filters(filters)
74
82
  if ransack_filters.present?
75
- search = scope.ransack(ransack_filters)
83
+ search = scope.ransack(ransack_filters, auth_object: @audience)
76
84
  scope = search.result(distinct: true)
77
85
  end
78
86
 
@@ -127,7 +135,7 @@ module Spree
127
135
  end
128
136
  }.join(',')
129
137
 
130
- scope.ransack(s: ransack_sort).result
138
+ scope.ransack({ s: ransack_sort }, auth_object: @audience).result
131
139
  end
132
140
  end
133
141
 
@@ -58,7 +58,9 @@ module Spree
58
58
  scope :owed, -> { where(status: %w[pending processing]) }
59
59
 
60
60
  self.whitelisted_ransackable_attributes = %w[amount currency status provider reference period_start period_end created_at seller_id]
61
- self.whitelisted_ransackable_associations = %w[seller transfers]
61
+ # No `transfers`: they lead on to the order and its buyer, which a
62
+ # payouts-only caller may not read.
63
+ self.whitelisted_ransackable_associations = %w[seller]
62
64
 
63
65
  extend Spree::DisplayMoney
64
66
  money_methods :amount
@@ -112,7 +112,9 @@ module Spree
112
112
  end
113
113
 
114
114
  self.whitelisted_ransackable_attributes = %w[amount currency kind status provider reference created_at seller_id order_id payout_id]
115
- self.whitelisted_ransackable_associations = %w[seller order payout refund]
115
+ # No `order`: filtering through it reaches the buyer's email and address,
116
+ # which a payouts-only caller may not read. `order_id` covers the lookup.
117
+ self.whitelisted_ransackable_associations = %w[seller payout refund]
116
118
 
117
119
  extend Spree::DisplayMoney
118
120
  money_methods :amount
@@ -25,6 +25,8 @@ module Spree
25
25
  include Spree::DeprecatedCustomerAlias
26
26
  belongs_to :created_by, class_name: Spree.admin_user_class.to_s, foreign_key: 'created_by_id', optional: true
27
27
  belongs_to :originator, polymorphic: true, optional: true
28
+ belongs_to :refunded_order, class_name: 'Spree::Order', optional: true,
29
+ inverse_of: :store_credit_refunds
28
30
 
29
31
  has_many :store_credit_events, class_name: 'Spree::StoreCreditEvent'
30
32
  has_many :payments, as: :source, class_name: 'Spree::Payment'
@@ -111,6 +113,8 @@ module Spree
111
113
  self.whitelisted_ransackable_attributes = %w[customer_id created_by_id amount currency memo]
112
114
  self.whitelisted_ransackable_associations = %w[customer created_by]
113
115
  self.whitelisted_ransackable_scopes = %w[outstanding from_gift_card]
116
+ # Staff notes and who issued the credit are back-office data.
117
+ self.private_ransackable_attributes = { store: %w[memo created_by_id] }
114
118
 
115
119
  # Two-state scopes: see Spree::Base.ransack_flag? for why the cast is
116
120
  # opted out of here and done inside each scope instead.
@@ -38,8 +38,10 @@ module Spree
38
38
  attr_reader :records
39
39
 
40
40
  # @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
41
- def initialize(entries)
41
+ # @param store [Spree::Store] the store every record must belong to
42
+ def initialize(entries, store: Spree::Store.current)
42
43
  @entries = Array(entries)
44
+ @store = store
43
45
  @records = []
44
46
  end
45
47
 
@@ -86,7 +88,7 @@ module Spree
86
88
  klass = resource_class(entry[:resource_type])
87
89
  raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
88
90
 
89
- relation = klass.respond_to?(:for_store) ? klass.for_store(Spree::Store.current) : klass
91
+ relation = klass.respond_to?(:translatable_scope) ? klass.translatable_scope(@store) : klass.for_store(@store)
90
92
  relation.find_by_prefix_id!(entry[:resource_id])
91
93
  rescue ActiveRecord::RecordNotFound
92
94
  raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
@@ -9,15 +9,19 @@ module Spree
9
9
 
10
10
  validates :provider, inclusion: {
11
11
  in: lambda { |_record|
12
- (Spree.store_authentication_strategies.keys + Spree.admin_authentication_strategies.keys).uniq.map(&:to_s)
12
+ (
13
+ Spree.store_authentication_strategies.keys +
14
+ Spree.admin_authentication_strategies.keys +
15
+ Spree.seller_authentication_strategies.keys
16
+ ).uniq.map(&:to_s)
13
17
  }
14
18
  }
15
19
 
16
- # Store provider-specific data
17
- # info: JSON field with provider-specific data (name, avatar, etc)
18
- # access_token: encrypted OAuth access token
19
- # refresh_token: encrypted OAuth refresh token
20
- # expires_at: token expiration timestamp
20
+ # Keys may come from config or from encrypted credentials. Rows written before
21
+ # encryption was enabled stay readable and are encrypted on their next write.
22
+ if ActiveRecord::Encryption.config.has_primary_key?
23
+ encrypts :access_token, :refresh_token, support_unencrypted_data: true
24
+ end
21
25
 
22
26
  # Find or create user from OAuth data
23
27
  def self.find_or_create_from_oauth(provider:, uid:, info:, tokens: {}, user_class: nil)
@@ -330,6 +330,7 @@ module Spree
330
330
  carton_package_type_id carton_weight cartons_per_pallet]
331
331
  self.whitelisted_ransackable_scopes = %i(product_name_or_sku_cont search_by_product_name_or_sku search
332
332
  available_at_stock_location)
333
+ self.private_ransackable_attributes = { store: %w[cost_price cost_currency deleted_at] }
333
334
 
334
335
  def self.product_name_or_sku_cont(query)
335
336
  sanitized_query = ActiveRecord::Base.sanitize_sql_like(query.to_s.downcase.strip)
@@ -604,8 +605,11 @@ module Spree
604
605
  # @param opt_type_position [Integer] the position of the option type
605
606
  # @return [void]
606
607
  def set_option_value(opt_name, opt_value, opt_type_position = nil)
607
- option_type = Spree::OptionType.where(name: opt_name.parameterize).first_or_initialize do |o|
608
+ # The product's own store: option types are store-owned, and matching one
609
+ # by name elsewhere would attach and extend another store's type.
610
+ option_type = Spree::OptionType.for_store(product.store).where(name: opt_name.parameterize).first_or_initialize do |o|
608
611
  o.name = o.label = opt_name
612
+ o.store = product.store
609
613
  o.save!
610
614
  end
611
615
 
@@ -1190,10 +1194,11 @@ module Spree
1190
1194
  end
1191
1195
 
1192
1196
  # Only the product's own store's locations — a new variant must not grow
1193
- # stock items in every other store's warehouses.
1197
+ # stock items in every other store's warehouses — and of those, a seller's
1198
+ # location only stocks what that seller sells.
1194
1199
  def create_stock_levels
1195
1200
  locations = product&.store ? product.store.stock_locations : StockLocation.all
1196
- locations.where(propagate_all_variants: true).each do |stock_location|
1201
+ locations.where(propagate_all_variants: true, seller_id: [nil, resolved_seller_id].uniq).each do |stock_location|
1197
1202
  stock_location.propagate_variant(self)
1198
1203
  end
1199
1204
  end
@@ -26,6 +26,34 @@ module Spree
26
26
  # Ransack configuration
27
27
  self.whitelisted_ransackable_attributes = %w[event_name response_code execution_time success delivered_at created_at]
28
28
 
29
+ # Event subjects whose permission is not found by class name alone.
30
+ PAYLOAD_SUBJECT_CLASSES = {
31
+ 'cart' => 'Spree::Order',
32
+ 'customer' => -> { Spree.customer_class },
33
+ 'newsletter_subscriber' => -> { Spree.customer_class }
34
+ }.freeze
35
+
36
+ # Event subjects whose payload holds no record of anyone's data.
37
+ PUBLIC_PAYLOAD_SUBJECTS = %w[webhook].freeze
38
+
39
+ # The catalog key a caller needs to read this delivery's payload. The
40
+ # payload is the full record the event is about — an order's addresses, a
41
+ # customer's email — so reading the log is no wider than reading the
42
+ # record. A subject no permission scope covers falls back to customer
43
+ # access, since an unknown payload may hold personal data.
44
+ #
45
+ # @return [String, nil] nil when anyone who can read the log may see it
46
+ def payload_permission_key
47
+ subject = event_name.to_s.split('.').first.to_s
48
+ return if PUBLIC_PAYLOAD_SUBJECTS.include?(subject)
49
+
50
+ source = PAYLOAD_SUBJECT_CLASSES[subject]
51
+ klass = source.respond_to?(:call) ? source.call : (source || "Spree::#{subject.camelize}").to_s.safe_constantize
52
+ scope = Spree.permissions.scope_for_resource(klass)
53
+
54
+ scope ? "read_#{scope.name}" : 'read_customers'
55
+ end
56
+
29
57
  # Check if the delivery was successful
30
58
  #
31
59
  # @return [Boolean]
@@ -50,11 +50,18 @@ module Spree
50
50
  # Number of consecutive failed deliveries before auto-disabling
51
51
  AUTO_DISABLE_THRESHOLD = 15
52
52
 
53
+ # Events whose payload carries a live customer credential (a password
54
+ # reset token). Receiving one is as good as holding the customer's account,
55
+ # so they reach only an endpoint that names them — never through `*` or a
56
+ # pattern — and the API asks for customer write access to point one at them.
57
+ CREDENTIAL_EVENTS = %w[customer.password_reset_requested].freeze
58
+
53
59
  # Check if this endpoint is subscribed to a specific event
54
60
  #
55
61
  # @param event_name [String] the event name to check
56
62
  # @return [Boolean]
57
63
  def subscribed_to?(event_name)
64
+ return subscriptions.to_a.include?(event_name) if CREDENTIAL_EVENTS.include?(event_name)
58
65
  return true if subscriptions.blank? || subscriptions.include?('*')
59
66
 
60
67
  subscriptions.any? do |subscription|
@@ -67,6 +74,13 @@ module Spree
67
74
  end
68
75
  end
69
76
 
77
+ # Whether this endpoint receives customer credentials.
78
+ #
79
+ # @return [Boolean]
80
+ def receives_credentials?
81
+ (subscriptions.to_a & CREDENTIAL_EVENTS).any?
82
+ end
83
+
70
84
  # Returns all events this endpoint is subscribed to
71
85
  #
72
86
  # @return [Array<String>]