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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
- data/app/models/concerns/spree/admin_user_methods.rb +23 -0
- data/app/models/concerns/spree/translatable_resource.rb +19 -0
- data/app/models/concerns/spree/user_roles.rb +9 -4
- data/app/models/spree/invitation.rb +11 -2
- data/app/models/spree/order.rb +3 -1
- data/app/models/spree/order_promotion.rb +3 -1
- data/app/models/spree/order_routing/strategy/rules.rb +8 -48
- data/app/models/spree/payment.rb +16 -1
- data/app/models/spree/payment_method/store_credit.rb +3 -1
- data/app/models/spree/payment_method.rb +14 -1
- data/app/models/spree/payment_source.rb +3 -1
- data/app/models/spree/promotion/actions/create_item_adjustments.rb +1 -1
- data/app/models/spree/promotion.rb +10 -1
- data/app/models/spree/promotion_handler/cart.rb +7 -1
- data/app/models/spree/promotion_handler/coupon.rb +1 -29
- data/app/models/spree/refresh_token.rb +26 -0
- data/app/models/spree/seller_transfer.rb +51 -0
- data/app/models/spree/stock/coordinator.rb +37 -17
- data/app/models/spree/stock/packer.rb +19 -9
- data/app/models/spree/stock/prioritizer.rb +30 -15
- data/app/models/spree/store.rb +35 -2
- data/app/models/spree/variant.rb +1 -1
- data/app/services/spree/carts/update.rb +2 -1
- data/app/services/spree/promotions/detach_from_owner.rb +72 -0
- data/app/services/spree/stock_reservations/reserve.rb +78 -26
- data/app/workflows/spree/carts/recalculate.rb +5 -1
- data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
- data/db/migrate/20260809150001_promote_shipping_categories_to_delivery_profiles.rb +15 -6
- data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
- data/db/migrate/20261030100001_scope_payment_sources_profile_id_uniqueness_to_payment_method.rb +38 -0
- data/db/migrate/20261031000001_add_last_active_at_to_spree_admin_users.rb +7 -0
- data/db/migrate/20261031100001_delete_orphaned_spree_option_value_variants.rb +10 -0
- data/lib/generators/spree/dummy/dummy_generator.rb +0 -1
- data/lib/generators/spree/dummy/templates/rails/database.yml +6 -9
- data/lib/generators/spree/dummy/templates/rails/test.rb +9 -5
- data/lib/generators/spree/dummy_model/templates/migration.rb.tt +1 -1
- data/lib/spree/checkout/default_requirements.rb +33 -5
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +1 -3
- data/lib/spree/testing_support/common_rake.rb +3 -2
- data/lib/spree/testing_support/lifecycle_events.rb +1 -3
- data/lib/spree/time_zones.rb +34 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +15 -1
- data/lib/tasks/delivery_migration.rake +17 -1
- data/lib/tasks/remove_orphan_order_promotions.rake +20 -0
- metadata +15 -78
- data/lib/generators/spree/authentication/dummy/dummy_generator.rb +0 -54
- data/lib/generators/spree/authentication/dummy/templates/authentication_helpers.rb.tt +0 -52
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95b9df74c721a763fd81c0ca389f4bf996400ed8e1a262ca031228387157be16
|
|
4
|
+
data.tar.gz: ff8cd8cc1294aaa4eea49a2fbcd0c6319b15ed59d77988af865d48ae56782bf2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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://
|
|
113
|
+
- [Official Documentation](https://spreecommerce.org/docs)
|
|
114
114
|
- [API Reference](https://api.spreecommerce.org)
|
|
115
|
-
- [Guides](https://
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
+
sending = sending.where.not(payouts_enabled_at: nil) if store.payout_provider_class.requires_payout_account?
|
|
51
52
|
|
|
52
|
-
|
|
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?
|
|
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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
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 ||
|
|
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 =
|
|
184
|
+
self.invitee = invitee_account
|
|
176
185
|
end
|
|
177
186
|
end
|
|
178
187
|
end
|
data/app/models/spree/order.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
43
|
-
order
|
|
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
|
data/app/models/spree/payment.rb
CHANGED
|
@@ -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?(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|