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
@@ -45,7 +45,12 @@ module Spree
45
45
  # back to base.
46
46
  # @return [Spree::Price]
47
47
  def find_price_from_lists
48
- (catalog_price_lists + applicable_price_lists).each do |price_list|
48
+ catalog_price_list_groups.each do |price_lists|
49
+ price = best_price_among(price_lists)
50
+ return price if price
51
+ end
52
+
53
+ applicable_price_lists.each do |price_list|
49
54
  price = find_price_for_list(price_list)
50
55
  return price if price
51
56
  end
@@ -53,12 +58,25 @@ module Spree
53
58
  nil
54
59
  end
55
60
 
56
- # Price lists reached through the buyer's effective catalogs, in
57
- # catalog resolution order — the company subtree, or failing that the
58
- # customer's groups, or failing that the channel's default catalog.
59
- # The same chain visibility uses, and its steps are alternatives: a
60
- # company buyer is priced by their company's agreement and never
61
- # also by their customer group's ({Spree::Catalog.for_context}).
61
+ # The cheapest price one node's lists give, nil when none prices this
62
+ # variant. Catalogs sharing a node rank equally, so taking the first to
63
+ # answer charged a company from whichever sorted first rather than the
64
+ # cheaper (docs/plans/6.0-b2b-companies-and-catalogs.md). Equal amounts
65
+ # keep list order, so the answer is stable.
66
+ #
67
+ # @param price_lists [Array<Spree::PriceList>]
68
+ # @return [Spree::Price, nil]
69
+ def best_price_among(price_lists)
70
+ price_lists.filter_map { |price_list| find_price_for_list(price_list) }.min_by(&:amount)
71
+ end
72
+
73
+ # Price lists reached through the buyer's effective catalogs, grouped
74
+ # by the node that assigns them and ordered nearest node first — the
75
+ # company subtree, or failing that the customer's groups, or failing
76
+ # that the channel's default catalog. The same chain visibility uses,
77
+ # and its steps are alternatives: a company buyer is priced by their
78
+ # company's agreement and never also by their customer group's
79
+ # ({Spree::Catalog.groups_for_context}).
62
80
  #
63
81
  # A catalog-attached list applies because the catalog applies: its
64
82
  # audience rules are not consulted, since the assignment already
@@ -68,10 +86,13 @@ module Spree
68
86
  # volume pricing is (docs/plans/6.0-price-list-automatic-pricing.md).
69
87
  # Status and dates gate the list as before.
70
88
  #
71
- # @return [Array<Spree::PriceList>]
72
- def catalog_price_lists
73
- @catalog_price_lists ||= catalogs_for_context.filter_map(&:price_list).uniq.select do |price_list|
74
- price_list.currently_active? && price_list.contextual_rules_applicable?(context)
89
+ # @return [Array<Array<Spree::PriceList>>]
90
+ def catalog_price_list_groups
91
+ @catalog_price_list_groups ||= catalog_groups_for_context.filter_map do |catalogs|
92
+ lists = catalogs.filter_map(&:price_list).select do |price_list|
93
+ price_list.currently_active? && price_list.contextual_rules_applicable?(context)
94
+ end
95
+ lists.presence
75
96
  end
76
97
  end
77
98
 
@@ -89,11 +110,12 @@ module Spree
89
110
  # Catalogs that apply to this buyer, through the one entry point
90
111
  # visibility and quantity terms also use — what a buyer is shown, what
91
112
  # they pay and how much they must take have to come from the same
92
- # agreement. Reuses the request-scoped set for the current store, so a
113
+ # agreement. Grouped by assigning node, since that is where precedence
114
+ # lives. Reuses the request-scoped set for the current store, so a
93
115
  # listing does not re-resolve the same buyer per variant.
94
- # @return [Array<Spree::Catalog>]
95
- def catalogs_for_context
96
- Spree::Catalog.for_buyer(
116
+ # @return [Array<Array<Spree::Catalog>>]
117
+ def catalog_groups_for_context
118
+ Spree::Catalog.groups_for_buyer(
97
119
  store: context.store, customer: context.user,
98
120
  company: context.company, channel: context.channel
99
121
  )
@@ -369,6 +369,10 @@ module Spree
369
369
  self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status available_on created_at updated_at seller_id]
370
370
  self.whitelisted_ransackable_associations = %w[categories collections store channels variants default_variant tags labels
371
371
  product_type product_categories option_types seller]
372
+ # The storefront filters by tag, category and collection; every other hop
373
+ # (variants, prices, channels) reaches back-office data, and variants lead
374
+ # back to the product, which lets one filter chain joins without end.
375
+ self.storefront_ransackable_associations = %w[tags categories collections]
372
376
  self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon in_category in_categories in_collection price_between
373
377
  price_lte price_gte
374
378
  search multi_search in_stock out_of_stock with_option_value_ids
@@ -7,5 +7,16 @@ module Spree
7
7
  acts_as_list scope: :product
8
8
 
9
9
  validates :product_id, uniqueness: { scope: :option_type_id }, allow_nil: true
10
+ validate :option_type_belongs_to_product_store
11
+
12
+ private
13
+
14
+ # Option types are store-owned, so a product may only use its own store's.
15
+ def option_type_belongs_to_product_store
16
+ return if product.nil? || option_type.nil?
17
+ return if option_type.store_id == product.store_id
18
+
19
+ errors.add(:option_type, :invalid)
20
+ end
10
21
  end
11
22
  end
@@ -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
@@ -2,6 +2,7 @@ module Spree
2
2
  class Refund < Spree.base_class
3
3
  has_prefix_id :re # Stripe: re_
4
4
 
5
+ include Spree::ActedBy
5
6
  include Spree::HasCustomFields
6
7
  include Spree::Metadata
7
8
  include Spree::InstrumentsGatewayCalls
@@ -18,7 +19,7 @@ module Spree
18
19
  # payment covers several and only one of them is being refunded.
19
20
  belongs_to :order, class_name: 'Spree::Order', optional: true, inverse_of: :refunds
20
21
  belongs_to :reason, class_name: 'Spree::RefundReason', foreign_key: :refund_reason_id
21
- belongs_to :refunder, class_name: Spree.admin_user_class.to_s, optional: true
22
+ acted_by :refunder
22
23
  # What triggered this refund — a Spree::Return today, later an Exchange
23
24
  # or Claim; nil for a manual refund. Deliberately polymorphic: the set is
24
25
  # small and closed, and refunds are never bulk-queried in a hot path.
@@ -14,6 +14,7 @@ module Spree
14
14
  has_prefix_id :ret
15
15
 
16
16
  has_spree_number prefix: 'RET'
17
+ include Spree::ActedBy
17
18
  include Spree::NumberIdentifier
18
19
  include Spree::SingleStoreResource
19
20
  include Spree::HasStatus
@@ -31,7 +32,7 @@ module Spree
31
32
  belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true, inverse_of: :returns
32
33
  # Staff only. Customer-initiated returns leave this nil — the requester
33
34
  # is always order.customer, so no second association is needed.
34
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
35
+ acted_by :created_by
35
36
 
36
37
  has_many :return_line_items, class_name: 'Spree::ReturnLineItem',
37
38
  dependent: :destroy, inverse_of: :return
@@ -110,21 +111,31 @@ module Spree
110
111
  end
111
112
  end
112
113
 
114
+ # Whether the warehouse has reported on this return.
115
+ #
116
+ # @return [Boolean]
117
+ def counted?
118
+ received? || refunded?
119
+ end
120
+
113
121
  # What the customer is owed for the items being returned.
114
122
  def refund_total
115
- return_line_items.sum(&:pre_tax_amount)
123
+ return_line_items.sum(&:refund_amount)
116
124
  end
117
125
 
118
- # What a refund from this return paid for, line by line. Counts what the
119
- # warehouse received rather than what the customer announced, so it agrees
120
- # 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.
121
129
  #
122
130
  # @return [Hash{Integer => BigDecimal}] line item id => amount
123
131
  def refunded_line_amounts
132
+ return {} unless counted?
133
+
124
134
  return_line_items.each_with_object(Hash.new(0)) do |line, amounts|
125
- next if line.quantity.to_i.zero? || line.received_quantity.to_i.zero?
135
+ amount = line.refund_amount
136
+ next if amount.zero?
126
137
 
127
- amounts[line.line_item_id] += (line.pre_tax_amount / line.quantity) * line.received_quantity.to_i
138
+ amounts[line.line_item_id] += amount
128
139
  end
129
140
  end
130
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