spree_core 6.0.0 → 6.0.2

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
  5. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
  6. data/app/models/concerns/spree/admin_user_methods.rb +23 -0
  7. data/app/models/concerns/spree/translatable_resource.rb +19 -0
  8. data/app/models/concerns/spree/user_roles.rb +9 -4
  9. data/app/models/spree/invitation.rb +11 -2
  10. data/app/models/spree/order.rb +3 -1
  11. data/app/models/spree/order_promotion.rb +3 -1
  12. data/app/models/spree/order_routing/strategy/rules.rb +8 -48
  13. data/app/models/spree/payment.rb +16 -1
  14. data/app/models/spree/payment_method/store_credit.rb +3 -1
  15. data/app/models/spree/payment_method.rb +14 -1
  16. data/app/models/spree/payment_source.rb +3 -1
  17. data/app/models/spree/promotion/actions/create_item_adjustments.rb +1 -1
  18. data/app/models/spree/promotion.rb +10 -1
  19. data/app/models/spree/promotion_handler/cart.rb +7 -1
  20. data/app/models/spree/promotion_handler/coupon.rb +1 -29
  21. data/app/models/spree/refresh_token.rb +26 -0
  22. data/app/models/spree/seller_transfer.rb +51 -0
  23. data/app/models/spree/stock/coordinator.rb +37 -17
  24. data/app/models/spree/stock/packer.rb +19 -9
  25. data/app/models/spree/stock/prioritizer.rb +30 -15
  26. data/app/models/spree/store.rb +35 -2
  27. data/app/models/spree/variant.rb +1 -1
  28. data/app/services/spree/carts/update.rb +2 -1
  29. data/app/services/spree/promotions/detach_from_owner.rb +72 -0
  30. data/app/services/spree/stock_reservations/reserve.rb +78 -26
  31. data/app/workflows/spree/carts/recalculate.rb +5 -1
  32. data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
  33. data/db/migrate/20260809150001_promote_shipping_categories_to_delivery_profiles.rb +15 -6
  34. data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
  35. data/db/migrate/20261030100001_scope_payment_sources_profile_id_uniqueness_to_payment_method.rb +38 -0
  36. data/db/migrate/20261031000001_add_last_active_at_to_spree_admin_users.rb +7 -0
  37. data/db/migrate/20261031100001_delete_orphaned_spree_option_value_variants.rb +10 -0
  38. data/lib/generators/spree/dummy/dummy_generator.rb +0 -1
  39. data/lib/generators/spree/dummy/templates/rails/database.yml +6 -9
  40. data/lib/generators/spree/dummy/templates/rails/test.rb +9 -5
  41. data/lib/generators/spree/dummy_model/templates/migration.rb.tt +1 -1
  42. data/lib/spree/checkout/default_requirements.rb +33 -5
  43. data/lib/spree/core/version.rb +1 -1
  44. data/lib/spree/core.rb +1 -3
  45. data/lib/spree/testing_support/common_rake.rb +3 -2
  46. data/lib/spree/testing_support/lifecycle_events.rb +1 -3
  47. data/lib/spree/time_zones.rb +34 -0
  48. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +15 -1
  49. data/lib/tasks/delivery_migration.rake +17 -1
  50. data/lib/tasks/remove_orphan_order_promotions.rake +20 -0
  51. metadata +15 -78
  52. data/lib/generators/spree/authentication/dummy/dummy_generator.rb +0 -54
  53. data/lib/generators/spree/authentication/dummy/templates/authentication_helpers.rb.tt +0 -52
  54. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 579b3e333d378aec89721e12502e86fe2f529d99e18f087ecaf29cebe6e6b772
4
- data.tar.gz: 29411570d9588634cfa2a4f559bea3bf6622c8672b90415a38ee1372a996494f
3
+ metadata.gz: 95b9df74c721a763fd81c0ca389f4bf996400ed8e1a262ca031228387157be16
4
+ data.tar.gz: ff8cd8cc1294aaa4eea49a2fbcd0c6319b15ed59d77988af865d48ae56782bf2
5
5
  SHA512:
6
- metadata.gz: 38eef7cff0fce557dd3b14460d3bbdf50e410fc28ae7e9c74fa59f92767078cc74531f221d7a615ba53af0297c3dd8bd9e72ab15a5f09dd5ab88ceff20b0e653
7
- data.tar.gz: dd2f38e2a6c8b179610ae41ded85992b051081ed71ac110e3a078ebd595b4ea273bbdabf6b64226af1831ce7e93eda1e32591dda4f5da9969dc700aaa3d6f587
6
+ metadata.gz: 94d512c61c6b2e6aa3c8672c883ea406af8efb8c509fe7ba0f0a49d58c02aa767d23a9ff82e8532965e32217130aeff39074c78d5b8d0896de6f8dac9e7e19e7
7
+ data.tar.gz: cb944067256bc44223bca95b0c00bcf17f242745c2885ea063d7549592ebdbb013c0138359fb60326b662ae4b4ed583fdba553fc2f42378fa3952049b69d7af2
data/README.md CHANGED
@@ -110,6 +110,6 @@ bundle exec rspec
110
110
 
111
111
  ## Documentation
112
112
 
113
- - [Official Documentation](https://docs.spreecommerce.org)
113
+ - [Official Documentation](https://spreecommerce.org/docs)
114
114
  - [API Reference](https://api.spreecommerce.org)
115
- - [Guides](https://docs.spreecommerce.org/developer)
115
+ - [Guides](https://spreecommerce.org/docs/developer)
@@ -1,6 +1,7 @@
1
1
  module Spree
2
2
  module SellerTransfers
3
- # Hands every seller holding an unconfirmed earning to a job of their own.
3
+ # Hands every seller holding an unconfirmed earning or reversal to a job of
4
+ # their own.
4
5
  #
5
6
  # The transfer level's counterpart to {Spree::SellerPayouts::SweepDueJob},
6
7
  # and deliberately a separate schedule rather than something the settlement
@@ -39,17 +40,18 @@ module Spree
39
40
  # @param store [Spree::Store]
40
41
  # @return [ActiveRecord::Relation<Spree::Seller>]
41
42
  def sellers_awaiting_provider(store)
42
- scope = store.sellers.approved.
43
- where(id: Spree::SellerTransfer.for_store(store).awaiting_provider.select(:seller_id))
43
+ transfers = Spree::SellerTransfer.for_store(store)
44
+ sending = store.sellers.approved.where(id: transfers.awaiting_provider.select(:seller_id))
44
45
 
45
46
  # A seller the provider will not accept yet keeps their earnings
46
47
  # unconfirmed, and the job would return at once. Skipped rather than
47
48
  # enqueued, because a marketplace's half-onboarded sellers are a
48
49
  # standing population and this runs often — and nothing is lost by it:
49
50
  # finishing onboarding runs the job for them directly.
50
- return scope unless store.payout_provider_class.requires_payout_account?
51
+ sending = sending.where.not(payouts_enabled_at: nil) if store.payout_provider_class.requires_payout_account?
51
52
 
52
- scope.where.not(payouts_enabled_at: nil)
53
+ # Money owed back is pursued whatever the seller's standing.
54
+ sending.or(store.sellers.where(id: transfers.reversals_awaiting_provider.select(:seller_id)))
53
55
  end
54
56
  end
55
57
  end
@@ -14,6 +14,9 @@ module Spree
14
14
  # seller who was payable all along would never see a refused earning tried
15
15
  # again, since nothing about them ever changes to prompt it.
16
16
  #
17
+ # Reversals are retried the same way: a provider that pulls money back can
18
+ # refuse too, and one cannot be pulled back before its earning was sent.
19
+ #
17
20
  # It takes the retryable rows — never sent, or refused outright. A transfer
18
21
  # whose outcome the provider could not report is left alone: asking again
19
22
  # is precisely how the same money moves twice, and only a person can say
@@ -24,30 +27,47 @@ module Spree
24
27
  # @param seller_id [Integer]
25
28
  def perform(seller_id)
26
29
  seller = Spree::Seller.find_by(id: seller_id)
27
- return if seller.nil? || !seller.payouts_enabled?
30
+ return if seller.nil?
28
31
 
29
32
  Spree::Current.store = seller.store
33
+ send_earnings(seller) if seller.payouts_enabled?
34
+
35
+ # Never through `transfer!`: a reversal's amount is negative, and the
36
+ # amount sent is its absolute value, so it would pay the seller the
37
+ # money it exists to take back. Through the provider that made the
38
+ # earning, which is not necessarily the store's current one. And for
39
+ # every seller, payable or not — a seller suspended after a wave of
40
+ # refunds is exactly the one whose money must come back.
41
+ seller.seller_transfers.reversals_awaiting_provider.includes(:reversed_from, :order).find_each do |reversal|
42
+ execute(reversal) { reversal.payout_provider_instance.reverse!(reversal) }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Before the reversals, so a reversal waiting on its earning finds it sent.
49
+ def send_earnings(seller)
30
50
  provider = seller.store.payout_provider_instance
31
51
 
32
- # Earnings only — see the scope. A reversal is retryable the same way
33
- # when its own provider call is refused, but sending one through
34
- # `transfer!` would pay the seller the amount it exists to take back:
35
- # the row is negative and the amount sent is its absolute value.
36
52
  seller.seller_transfers.awaiting_provider.find_each do |transfer|
37
- provider.transfer!(transfer)
38
- rescue Spree::Core::AmbiguousGatewayError => e
39
- # The money may already have moved, so this row must not be offered
40
- # again: left retryable, the next run would send it a second time,
41
- # and an idempotency key stops that only while the provider still
42
- # holds its record.
43
- transfer.update!(status: 'unresolved')
44
- Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
45
- rescue StandardError => e
46
- # A refusal — the money did not move. One seller's stuck earning must
47
- # not stop the rest, and the row stays retryable for the next run.
48
- Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
53
+ execute(transfer) { provider.transfer!(transfer) }
49
54
  end
50
55
  end
56
+
57
+ def execute(transfer)
58
+ yield
59
+ rescue Spree::Core::AmbiguousGatewayError => e
60
+ # The money may already have moved, so this row must not be offered
61
+ # again: left retryable, the next run would move it a second time,
62
+ # and an idempotency key stops that only while the provider still
63
+ # holds its record.
64
+ transfer.update!(status: 'unresolved')
65
+ Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
66
+ rescue StandardError => e
67
+ # A refusal — the money did not move. One stuck row must not stop the
68
+ # rest, and it stays retryable for the next run.
69
+ Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
70
+ end
51
71
  end
52
72
  end
53
73
  end
@@ -0,0 +1,63 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # 5.6 → 6.0 conversion of the store's time zone into an IANA identifier
5
+ # ("Eastern Time (US & Canada)" becomes "America/New_York") and of its
6
+ # admin language into a listed locale code (`en_US` becomes `en-US`), the
7
+ # only forms the API accepts and browsers read.
8
+ #
9
+ # A time zone that names no zone falls back to the store default, which is
10
+ # what the store already ran on: Ruby could not read it either. An admin
11
+ # language Spree does not list is cleared, so the dashboard shows each
12
+ # staff member their own language.
13
+ class StoreRegionalSettings < Spree::MaintenanceTask
14
+ supports_dry_run
15
+
16
+ def collection
17
+ Spree::Store.with_deleted.order(:id)
18
+ end
19
+
20
+ # Locks the store's row, so a settings save made meanwhile is read
21
+ # rather than overwritten, and counts each change once it is written.
22
+ #
23
+ # @param store [Spree::Store]
24
+ # @return [void]
25
+ def process(store)
26
+ store.with_lock do
27
+ changes = time_zone_change(store).merge(admin_locale_change(store))
28
+ next if changes.empty?
29
+
30
+ if dry_run?
31
+ changes.each_value { |outcome, _value| tally(:"would_#{outcome}") }
32
+ next
33
+ end
34
+
35
+ changes.each { |name, (_outcome, value)| store.set_preference(name, value) }
36
+ store.update_columns(preferences: store.preferences, updated_at: Time.current)
37
+ changes.each_value { |outcome, _value| tally(outcome) }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def time_zone_change(store)
44
+ stored = store.preferred_timezone
45
+ return {} if Spree::TimeZones.valid?(stored)
46
+
47
+ converted = Spree::TimeZones.normalize(stored)
48
+ return { timezone: [:time_zone_converted, converted] } if Spree::TimeZones.valid?(converted)
49
+
50
+ { timezone: [:time_zone_reset_to_default, store.preference_default(:timezone)] }
51
+ end
52
+
53
+ def admin_locale_change(store)
54
+ stored = store.preferred_admin_locale
55
+ return {} if stored.blank? || Spree::Locales::ALL.include?(stored)
56
+
57
+ listed = Spree::Locales.listed(stored)
58
+ { admin_locale: listed ? [:admin_locale_converted, listed] : [:admin_locale_cleared, nil] }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -2,6 +2,8 @@ module Spree
2
2
  module AdminUserMethods
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ STATUSES = %w[active locked deactivated].freeze
6
+
5
7
  include Spree::Actor
6
8
  include Spree::PrefixedId
7
9
  include Spree::SignedIdAttachments
@@ -43,6 +45,8 @@ module Spree
43
45
 
44
46
  # Associations
45
47
  has_many :identities, class_name: 'Spree::UserIdentity', as: :user, dependent: :destroy
48
+ # Signed-in sessions, on the admin and the seller API alike.
49
+ has_many :refresh_tokens, class_name: 'Spree::RefreshToken', as: :user, dependent: :delete_all
46
50
  # The order operations became polymorphic in 6.0, so these read through
47
51
  # the association rather than a bare foreign key: matching on the id
48
52
  # alone would hand this user the rows an API key with the same numeric
@@ -84,6 +88,25 @@ module Spree
84
88
  Spree::Store.current.users.where.not(id: id).exists?
85
89
  end
86
90
 
91
+ # Whether the account can sign in. Duck-typed: a custom admin user class
92
+ # may lack deactivation or lockout.
93
+ # @return [String] +active+, +locked+ or +deactivated+
94
+ def status
95
+ if try(:deactivated?)
96
+ 'deactivated'
97
+ elsif try(:locked?)
98
+ 'locked'
99
+ else
100
+ 'active'
101
+ end
102
+ end
103
+
104
+ # Sessions that can still be refreshed, most recently used first.
105
+ # @return [ActiveRecord::Relation<Spree::RefreshToken>]
106
+ def active_sessions
107
+ refresh_tokens.active.order(created_at: :desc)
108
+ end
109
+
87
110
  # Returns the full name of the user
88
111
  # @return [String]
89
112
  def full_name
@@ -5,6 +5,9 @@ module Spree
5
5
  included do
6
6
  extend Mobility
7
7
  default_scope { i18n }
8
+
9
+ # Before the translations are autosaved, so it has to run first.
10
+ after_update :discard_blank_new_translations, prepend: true
8
11
  end
9
12
 
10
13
  class_methods do
@@ -178,6 +181,22 @@ module Spree
178
181
 
179
182
  private
180
183
 
184
+ # Clearing a translation's last value deletes its row, and Mobility then
185
+ # rebuilds a blank one for that locale while it finalizes dirty tracking.
186
+ # Saved, that blank row is either junk or, where translation rows are soft
187
+ # deleted (stores), a duplicate of the deleted row.
188
+ def discard_blank_new_translations
189
+ return unless association(:translations).loaded?
190
+
191
+ columns = self.class.translatable_fields.map(&:to_s)
192
+ translations.each do |translation|
193
+ next unless translation.new_record?
194
+ next if columns.any? { |column| translation[column].present? }
195
+
196
+ translation.mark_for_destruction
197
+ end
198
+ end
199
+
181
200
  # @raise [ActiveRecord::RecordInvalid] if any locale isn't supported by the store
182
201
  def validate_translation_locales!(locales)
183
202
  supported = translatable_store&.supported_locales_list || []
@@ -57,10 +57,15 @@ module Spree
57
57
  has_spree_role?(Spree::Role::ADMIN_ROLE, resource)
58
58
  end
59
59
 
60
- # Returns the user who invited the current user
61
- # @return [Spree.admin_user_class]
62
- def invited_by
63
- invitations.first&.inviter
60
+ # Who sent the invitation this user accepted to get a role on +resource+.
61
+ # Nil for a user given a role without one, such as the store owner.
62
+ #
63
+ # @param resource [Spree::Base] the store or seller; defaults to the current store
64
+ # @return [Object, nil] an actor (see Spree.actor_classes)
65
+ def invited_by(resource = nil)
66
+ resource ||= Spree::Store.current
67
+
68
+ invitations.accepted.where(resource: resource).order(:created_at).first&.inviter
64
69
  end
65
70
 
66
71
  # Whether this user belongs to any marketplace seller. The seller login
@@ -116,6 +116,15 @@ module Spree
116
116
  publish_event('invitation.resent')
117
117
  end
118
118
 
119
+ # The staff account holding the invited address, if one exists. Matched
120
+ # the way the account's email uniqueness is, case-insensitively, so an
121
+ # address typed in another case still finds the one account it can belong
122
+ # to. Resolved by record rather than by comparing strings in Ruby, whose
123
+ # case folding is wider than the database's.
124
+ def invitee_account
125
+ Spree.admin_user_class.where('LOWER(email) = LOWER(?)', email).first
126
+ end
127
+
119
128
  private
120
129
 
121
130
  def publish_invitation_created_event
@@ -161,7 +170,7 @@ module Spree
161
170
  return if resource.blank?
162
171
 
163
172
  # Check role_users directly for better performance and to avoid potential source_type issues
164
- user_to_check = invitee || Spree.admin_user_class.find_by(email: email)
173
+ user_to_check = invitee || invitee_account
165
174
  return if user_to_check.blank?
166
175
 
167
176
  if resource.role_users.exists?(user: user_to_check)
@@ -172,7 +181,7 @@ module Spree
172
181
  def set_invitee_from_email
173
182
  return if invitee.present?
174
183
 
175
- self.invitee = Spree.admin_user_class.find_by(email: email)
184
+ self.invitee = invitee_account
176
185
  end
177
186
  end
178
187
  end
@@ -337,8 +337,10 @@ module Spree
337
337
  scope :reverse_chronological, -> { order(Arel.sql('spree_orders.completed_at IS NULL'), completed_at: :desc, created_at: :desc) }
338
338
 
339
339
  def self.search(query)
340
+ # Ransack passes `true` when the scope is called as a bare flag (`q[search]=1`)
341
+ return none unless query.is_a?(String) && query.present?
342
+
340
343
  sanitized_query = sanitize_query_for_search(query)
341
- return none if query.blank?
342
344
 
343
345
  query_pattern = "%#{sanitized_query}%"
344
346
 
@@ -8,7 +8,7 @@ module Spree
8
8
  belongs_to :cart, class_name: 'Spree::Cart', optional: true, inverse_of: :order_promotions
9
9
  belongs_to :promotion, class_name: 'Spree::Promotion'
10
10
 
11
- delegate :name, :description, :code, to: :promotion
11
+ delegate :name, :description, :code, to: :promotion, allow_nil: true
12
12
  delegate :currency, to: :owner
13
13
 
14
14
  validates :order, uniqueness: { scope: :promotion }, allow_nil: true
@@ -35,6 +35,8 @@ module Spree
35
35
  end
36
36
 
37
37
  def amount
38
+ return BigDecimal(0) if promotion.nil?
39
+
38
40
  owner.discounts.promotion.where(promotion_action_id: promotion.actions.ids).sum(:amount)
39
41
  end
40
42
 
@@ -10,7 +10,7 @@ module Spree
10
10
  # See docs/plans/6.0-order-routing.md.
11
11
  class Rules < Base
12
12
  def for_allocation
13
- locations = eligible_locations
13
+ locations = coordinator.candidate_stock_locations
14
14
  return [] if locations.empty?
15
15
 
16
16
  ordered = Spree::OrderRouting::Strategy::Reducer
@@ -18,9 +18,11 @@ module Spree
18
18
  .rank_all(locations)
19
19
  return [] if ordered.empty?
20
20
 
21
- packages = build_packages(ordered)
22
- packages = prioritize_packages(packages)
23
- estimate_rates(packages)
21
+ # Packed in rank order through the Coordinator, so an order allocates
22
+ # under the same delivery profile and channel coverage as its cart,
23
+ # and the Prioritizer's first-package-wins-on-hand logic honors the
24
+ # routing decision between origins that can reach the destination.
25
+ coordinator.estimate_packages(coordinator.allocated_packages(ordered))
24
26
  end
25
27
 
26
28
  # Stock decrement / restock today happens via Spree::Fulfillment's state
@@ -39,50 +41,8 @@ module Spree
39
41
  order.channel.order_routing_rules.active.ordered
40
42
  end
41
43
 
42
- def eligible_locations
43
- order.store.stock_locations.active
44
- .joins(:stock_levels)
45
- .where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids })
46
- .distinct
47
- .to_a
48
- end
49
-
50
- def requested_variant_ids
51
- inventory_units.map(&:variant_id).uniq
52
- end
53
-
54
- def inventory_units
55
- @inventory_units ||= Spree::Stock::InventoryUnitBuilder.new(order).units
56
- end
57
-
58
- # Pack each ranked location independently. Packages are emitted in
59
- # rank order so the Prioritizer's first-package-wins-on-hand logic
60
- # honors the routing decision.
61
- def build_packages(locations)
62
- stock_levels = Spree::Stock::Packer.stock_levels_by_location(locations, requested_variant_ids)
63
-
64
- locations.flat_map do |location|
65
- Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order,
66
- stock_levels: stock_levels.fetch(location.id, {})).packages
67
- end
68
- end
69
-
70
- # Prioritizer's Adjuster distributes each inventory_unit across
71
- # packages: the first package with on-hand stock fulfills the unit,
72
- # and downstream packages have that unit removed. Packages whose
73
- # items all get stripped are pruned. Rank only decides between origins
74
- # that can reach the destination — the Prioritizer demotes the rest.
75
- def prioritize_packages(packages)
76
- Spree::Stock::Prioritizer.new(packages, estimator: estimator).prioritized_packages
77
- end
78
-
79
- def estimate_rates(packages)
80
- packages.each { |pkg| pkg.delivery_rates = estimator.delivery_rates(pkg) }
81
- packages
82
- end
83
-
84
- def estimator
85
- @estimator ||= Spree::Stock::Estimator.new(order)
44
+ def coordinator
45
+ @coordinator ||= Spree::Stock::Coordinator.new(order)
86
46
  end
87
47
  end
88
48
  end
@@ -27,6 +27,11 @@ module Spree
27
27
  INVALID_STATES = INVALID_STATUSES
28
28
  deprecate_constant :INVALID_STATES
29
29
 
30
+ # Statuses a payment can be voided / captured from, before the payment
31
+ # method is consulted. Extend them to support gateways with other flows.
32
+ VOIDABLE_STATUSES = %w[pending processing completed checkout].freeze
33
+ CAPTURABLE_STATUSES = %w[checkout pending].freeze
34
+
30
35
  with_options inverse_of: :payments do
31
36
  belongs_to :order, class_name: 'Spree::Order', touch: true, optional: true
32
37
  belongs_to :cart, class_name: 'Spree::Cart', optional: true
@@ -219,8 +224,18 @@ module Spree
219
224
  status.in?(%w[checkout processing])
220
225
  end
221
226
 
227
+ # Whether the payment is in a status that can be voided and its payment
228
+ # method agrees. A gateway that cannot void a capture returns false from
229
+ # +can_void?(payment)+ for completed payments.
222
230
  def can_void?
223
- status.in?(%w[pending processing completed checkout])
231
+ status.in?(VOIDABLE_STATUSES) &&
232
+ (!payment_method.respond_to?(:can_void?) || payment_method.can_void?(self))
233
+ end
234
+
235
+ # Whether the payment can be captured from the back office.
236
+ def can_capture?
237
+ status.in?(CAPTURABLE_STATUSES) &&
238
+ (!payment_method.respond_to?(:can_capture?) || payment_method.can_capture?(self))
224
239
  end
225
240
 
226
241
  # @deprecated read +status+ — removed in 6.1
@@ -18,8 +18,10 @@ module Spree
18
18
  ['checkout', 'pending'].include?(payment.status)
19
19
  end
20
20
 
21
+ # Mirrors Spree::StoreCredit#can_void?: a checkout payment can still be
22
+ # voided while its order is incomplete.
21
23
  def can_void?(payment)
22
- payment.pending?
24
+ payment.pending? || (payment.checkout? && !payment.order&.completed?)
23
25
  end
24
26
 
25
27
  def authorize(money, store_credit, gateway_options = {})
@@ -197,7 +197,7 @@ module Spree
197
197
  def webhook_url
198
198
  return nil unless store
199
199
 
200
- "#{store.url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"
200
+ "#{store.formatted_url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"
201
201
  end
202
202
 
203
203
  class WebhookSignatureError < StandardError; end
@@ -309,6 +309,19 @@ module Spree
309
309
  raise ::NotImplementedError, 'You must implement cancel method for this payment method.'
310
310
  end
311
311
 
312
+ # Whether the payment can be voided in its current state. Gateways that
313
+ # cannot void a captured payment override this.
314
+ # @param _payment [Spree::Payment]
315
+ def can_void?(_payment)
316
+ true
317
+ end
318
+
319
+ # Whether the payment can be captured in its current state.
320
+ # @param _payment [Spree::Payment]
321
+ def can_capture?(_payment)
322
+ true
323
+ end
324
+
312
325
  def store_credit?
313
326
  self.class == Spree::PaymentMethod::StoreCredit
314
327
  end
@@ -17,7 +17,9 @@ module Spree
17
17
  #
18
18
  # Validations
19
19
  #
20
- validates_uniqueness_of :gateway_payment_profile_id, scope: :type
20
+ # A gateway profile id is only unique within the gateway account that issued it, so the same
21
+ # buyer needs a separate source under each payment method they pay through.
22
+ validates_uniqueness_of :gateway_payment_profile_id, scope: %i[type payment_method_id]
21
23
 
22
24
  #
23
25
  # Delegations
@@ -22,7 +22,7 @@ module Spree
22
22
  # clamped against its line's remaining discounted base, so the order net
23
23
  # can never go below zero.
24
24
  def compute_amount(line_item)
25
- return 0 unless promotion.line_item_actionable?(line_item.order, line_item)
25
+ return 0 unless promotion.line_item_actionable?(line_item.owner, line_item)
26
26
 
27
27
  [line_item.amount, compute(line_item)].min * -1
28
28
  end
@@ -51,7 +51,8 @@ module Spree
51
51
  before_validation :set_starts_at_to_current_time, if: -> { starts_at.blank? }
52
52
  after_commit :generate_coupon_codes, if: -> { multi_codes? }, on: [:create, :update]
53
53
  after_commit :remove_coupons, on: :update
54
- before_destroy :not_used?
54
+ before_destroy :detach_from_open_carts, prepend: true
55
+ before_destroy :not_used?, prepend: true
55
56
 
56
57
  #
57
58
  # Validations
@@ -376,6 +377,14 @@ module Spree
376
377
  flush_pending_typed_association(:promotion_actions)
377
378
  end
378
379
 
380
+ # Runs before the promotion's actions are destroyed, since the carts'
381
+ # discounts are tied back to the promotion through them.
382
+ def detach_from_open_carts
383
+ Spree::Cart.where(id: order_promotions.where.not(cart_id: nil).select(:cart_id)).find_each do |cart|
384
+ Spree::Promotions::DetachFromOwner.call(owner: cart, promotion: self, clear_coupon_code: true)
385
+ end
386
+ end
387
+
379
388
  def not_used?
380
389
  return true if orders.empty?
381
390
 
@@ -16,14 +16,20 @@ module Spree
16
16
  attr_reader :line_item, :order, :store
17
17
  attr_accessor :error, :success
18
18
 
19
- def initialize(order, line_item = nil)
19
+ # @param order [Spree::Cart, Spree::Order]
20
+ # @param line_item [Spree::LineItem, nil]
21
+ # @param excluded_promotion_ids [Array<String, Integer>] promotions to leave alone, e.g. one that is being removed
22
+ def initialize(order, line_item = nil, excluded_promotion_ids: [])
20
23
  @order = order
21
24
  @line_item = line_item
22
25
  @store = order.store
26
+ @excluded_promotion_ids = Array(excluded_promotion_ids).map(&:to_s)
23
27
  end
24
28
 
25
29
  def activate
26
30
  promotions.each do |promotion|
31
+ next if @excluded_promotion_ids.include?(promotion.id.to_s)
32
+
27
33
  if (line_item && promotion.eligible?(line_item)) || promotion.eligible?(order)
28
34
  promotion.activate(line_item: line_item, order: order)
29
35
  else
@@ -166,35 +166,7 @@ module Spree
166
166
  end
167
167
 
168
168
  def detach_promotion(promotion)
169
- # Order promotion has to be destroyed before line item removing
170
- order.promotions.delete(promotion)
171
-
172
- if promotion.multi_codes?
173
- promotion.coupon_codes.held_by(order).first&.remove_from_order
174
- else
175
- promotion.touch
176
- end
177
-
178
- remove_promotion_adjustments(promotion)
179
- remove_promotion_line_items(promotion)
180
- order.recalculate_totals!
181
- end
182
-
183
- def remove_promotion_adjustments(promotion)
184
- order.discounts.where(promotion_action_id: promotion.actions.pluck(:id)).destroy_all
185
- end
186
-
187
- def remove_promotion_line_items(promotion)
188
- create_line_item_actions_ids = promotion.actions.
189
- where(type: %w[Spree::Promotion::Actions::CreateLineItems Spree::Promotion::Actions::CreateLineItems]).pluck(:id)
190
-
191
- Spree::PromotionActionLineItem.where(promotion_action: create_line_item_actions_ids).find_each do |item|
192
- line_item = order.find_line_item_by_variant(item.variant)
193
- next if line_item.blank?
194
-
195
- remove_service = order.is_a?(Spree::Cart) ? Spree.cart_remove_item_service : Spree.order_remove_item_service
196
- remove_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order }, variant: item.variant, quantity: item.quantity)
197
- end
169
+ Spree::Promotions::DetachFromOwner.call(owner: order, promotion: promotion)
198
170
  end
199
171
 
200
172
  def handle_present_promotion