spree_core 6.0.0.beta2 → 6.0.0.beta3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
- data/app/jobs/spree/sample_data/load_job.rb +7 -2
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
- data/app/mailers/spree/admin_user_mailer.rb +2 -2
- data/app/models/concerns/spree/customer_methods.rb +17 -1
- data/app/models/concerns/spree/has_number.rb +3 -2
- data/app/models/concerns/spree/prefixed_id.rb +4 -1
- data/app/models/concerns/spree/purchase/totals.rb +3 -1
- data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
- data/app/models/concerns/spree/translatable_resource.rb +6 -7
- data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
- data/app/models/spree/adjusters/promotion.rb +1 -1
- data/app/models/spree/authentication/lockout.rb +34 -0
- data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
- data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
- data/app/models/spree/claim.rb +1 -0
- data/app/models/spree/commission_line.rb +4 -2
- data/app/models/spree/commission_rule.rb +1 -1
- data/app/models/spree/coupon_code.rb +14 -2
- data/app/models/spree/current.rb +15 -0
- data/app/models/spree/exchange.rb +1 -0
- data/app/models/spree/export.rb +8 -5
- data/app/models/spree/exports/customers.rb +2 -2
- data/app/models/spree/fulfillment_group.rb +127 -0
- data/app/models/spree/gift_card.rb +9 -0
- data/app/models/spree/invitation.rb +18 -5
- data/app/models/spree/number_generators/sequential.rb +2 -2
- data/app/models/spree/option_value.rb +7 -0
- data/app/models/spree/order.rb +35 -12
- data/app/models/spree/order_group.rb +78 -6
- data/app/models/spree/payment_source.rb +1 -1
- data/app/models/spree/payment_split.rb +24 -0
- data/app/models/spree/price.rb +74 -0
- data/app/models/spree/product.rb +4 -0
- data/app/models/spree/product_option_type.rb +11 -0
- data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
- data/app/models/spree/promotion/rules/category.rb +14 -9
- data/app/models/spree/promotion/rules/product.rb +17 -1
- data/app/models/spree/promotion.rb +1 -1
- data/app/models/spree/promotion_action.rb +12 -1
- data/app/models/spree/promotion_handler/coupon.rb +50 -5
- data/app/models/spree/promotion_rule.rb +14 -0
- data/app/models/spree/return.rb +16 -6
- data/app/models/spree/return_line_item.rb +19 -0
- data/app/models/spree/search_provider/database.rb +10 -2
- data/app/models/spree/seller_payout.rb +3 -1
- data/app/models/spree/seller_transfer.rb +3 -1
- data/app/models/spree/store_credit.rb +4 -0
- data/app/models/spree/translations/batch.rb +4 -2
- data/app/models/spree/user_identity.rb +10 -6
- data/app/models/spree/variant.rb +8 -3
- data/app/models/spree/webhook_delivery.rb +28 -0
- data/app/models/spree/webhook_endpoint.rb +14 -0
- data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
- data/app/services/spree/carts/split_by_seller.rb +22 -12
- data/app/services/spree/companies/add_member.rb +8 -2
- data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
- data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
- data/app/services/spree/order_groups/allocate_payments.rb +3 -0
- data/app/services/spree/orders/build_fulfillments.rb +6 -0
- data/app/services/spree/orders/update_statuses.rb +8 -2
- data/app/services/spree/prices/bulk_upsert.rb +186 -47
- data/app/services/spree/sample_data/import_builder.rb +1 -1
- data/app/services/spree/sample_data/loader.rb +54 -40
- data/app/services/spree/seeds/all.rb +1 -26
- data/app/services/spree/seeds/allowed_origins.rb +5 -4
- data/app/services/spree/seeds/api_keys.rb +10 -9
- data/app/services/spree/seeds/channels.rb +9 -8
- data/app/services/spree/seeds/commission_rates.rb +21 -20
- data/app/services/spree/seeds/customer_groups.rb +5 -4
- data/app/services/spree/seeds/digital_delivery.rb +7 -8
- data/app/services/spree/seeds/payment_methods.rb +12 -11
- data/app/services/spree/seeds/product_types.rb +7 -8
- data/app/services/spree/seeds/returns_environment.rb +12 -11
- data/app/services/spree/seeds/roles.rb +8 -7
- data/app/services/spree/seeds/saved_reports.rb +19 -20
- data/app/services/spree/seeds/seller_requirements.rb +5 -4
- data/app/services/spree/seeds/store_resources.rb +41 -0
- data/app/services/spree/seeds/store_scoped.rb +15 -0
- data/app/services/spree/seeds/tax_categories.rb +10 -9
- data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
- data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
- data/app/subscribers/spree/order_status_subscriber.rb +15 -7
- data/app/workflows/spree/carts/add_item.rb +5 -7
- data/app/workflows/spree/carts/complete.rb +20 -64
- data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
- data/app/workflows/spree/claims/create.rb +31 -1
- data/app/workflows/spree/claims/resolve.rb +7 -11
- data/app/workflows/spree/exchanges/create.rb +11 -0
- data/app/workflows/spree/exchanges/fulfill.rb +21 -13
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/create.rb +9 -15
- data/app/workflows/spree/returns/refund.rb +15 -24
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +7 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
- data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
- data/db/sample_data/channels.rb +1 -1
- data/db/sample_data/collections.rb +1 -1
- data/db/sample_data/custom_field_definitions.rb +1 -1
- data/db/sample_data/digital_products.rb +1 -1
- data/db/sample_data/fulfillment.rb +1 -1
- data/db/sample_data/options.rb +4 -2
- data/db/sample_data/orders.rb +9 -10
- data/db/sample_data/payment_methods.rb +18 -18
- data/db/sample_data/product_type_categories.rb +1 -1
- data/db/sample_data/product_types.rb +2 -2
- data/db/sample_data/promotions.rb +2 -4
- data/db/sample_data/wholesale.rb +1 -1
- data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
- data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/engine.rb +8 -8
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/iso_data.rb +23 -4
- data/lib/spree/store_scope_guard.rb +16 -1
- data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
- data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
- data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/upgrade.rake +6 -21
- metadata +13 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# One real parcel of a purchase, across however many orders it belongs to.
|
|
3
|
+
#
|
|
4
|
+
# A checkout that spans several sellers divides into one order per seller,
|
|
5
|
+
# and a parcel holding more than one seller's goods divides with them: the
|
|
6
|
+
# split clones the fulfillment onto each sibling and apportions the one
|
|
7
|
+
# delivery charge the customer was quoted between the halves
|
|
8
|
+
# (+Spree::Carts::SplitBySeller+). So "how many orders" and "how many
|
|
9
|
+
# parcels" are different numbers, and anything telling a customer what to
|
|
10
|
+
# expect has to answer the second — three orders shipping out of one
|
|
11
|
+
# warehouse are still one box, and saying otherwise promises deliveries that
|
|
12
|
+
# will never arrive.
|
|
13
|
+
#
|
|
14
|
+
# Ephemeral, never persisted: it is a reading of the fulfillments, and they
|
|
15
|
+
# remain the record.
|
|
16
|
+
class FulfillmentGroup
|
|
17
|
+
include ActiveModel::Model
|
|
18
|
+
include ActiveModel::Attributes
|
|
19
|
+
|
|
20
|
+
attr_accessor :fulfillments
|
|
21
|
+
|
|
22
|
+
# Collapses divided halves back into the parcels that actually ship.
|
|
23
|
+
#
|
|
24
|
+
# Halves of a divided parcel share their origin and their delivery method,
|
|
25
|
+
# because the clone copies both. Genuinely separate parcels differ in at
|
|
26
|
+
# least one: a seller shipping from their own warehouse differs in origin
|
|
27
|
+
# even when quoted on a marketplace method the operator shares with them
|
|
28
|
+
# (+DeliveryMethod#available_to_sellers+), and two parcels leaving one
|
|
29
|
+
# warehouse differ in method. The pair is safe as an identity because the
|
|
30
|
+
# packer builds one package per stock location per order, so it cannot
|
|
31
|
+
# produce two same-origin same-method fulfillments on its own — only the
|
|
32
|
+
# split can.
|
|
33
|
+
#
|
|
34
|
+
# @param fulfillments [Enumerable<Spree::Fulfillment>]
|
|
35
|
+
# @return [Array<Spree::FulfillmentGroup>] in the order the fulfillments
|
|
36
|
+
# were given, so a caller that ordered them decides how the parcels read
|
|
37
|
+
def self.build_from(fulfillments)
|
|
38
|
+
fulfillments.group_by { |fulfillment| identity(fulfillment) }.
|
|
39
|
+
map { |_identity, members| new(fulfillments: members) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Array] origin and delivery method — see {.build_from}
|
|
43
|
+
def self.identity(fulfillment)
|
|
44
|
+
[fulfillment.stock_location_id, fulfillment.selected_delivery_rate&.delivery_method_id]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Spree::DeliveryMethod, nil]
|
|
48
|
+
def delivery_method
|
|
49
|
+
primary.delivery_method
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# What the customer chose to be delivered by, named as they were shown it —
|
|
53
|
+
# a provider rate carries its own label ("UPS Ground"), a calculated one
|
|
54
|
+
# reads through to the method.
|
|
55
|
+
#
|
|
56
|
+
# @return [String, nil]
|
|
57
|
+
def name
|
|
58
|
+
primary.selected_delivery_rate&.name || delivery_method&.name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Spree::StockLocation, nil]
|
|
62
|
+
def stock_location
|
|
63
|
+
primary.stock_location
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# What this parcel costs to deliver: the halves' apportioned shares added
|
|
67
|
+
# back up, which is what the customer was quoted for it before the split
|
|
68
|
+
# divided anything.
|
|
69
|
+
#
|
|
70
|
+
# @return [BigDecimal]
|
|
71
|
+
def cost
|
|
72
|
+
fulfillments.sum { |fulfillment| fulfillment.discounted_cost.to_d }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [String, Spree::Money] the forwarder's placeholder while a
|
|
76
|
+
# freight rate is unpriced, money otherwise
|
|
77
|
+
def display_cost(**options)
|
|
78
|
+
return primary.display_cost(**options) if unpriced?
|
|
79
|
+
|
|
80
|
+
Spree::Money.new(cost, { currency: primary.currency }.merge(options))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def unpriced?
|
|
85
|
+
fulfillments.any?(&:unpriced?)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Array<Spree::LineItem>] every line this parcel carries part of
|
|
89
|
+
def line_items
|
|
90
|
+
fulfillments.flat_map(&:line_items).uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What the parcel carries, each line with the quantity *this* parcel holds.
|
|
94
|
+
#
|
|
95
|
+
# A line item can be packed from two warehouses, which puts it in two
|
|
96
|
+
# parcels — printing the line's own quantity in both would show the
|
|
97
|
+
# customer more goods than they bought, and charge for them twice.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Spree::Fulfillment::ManifestItem>]
|
|
100
|
+
def manifest
|
|
101
|
+
fulfillments.flat_map(&:manifest).group_by(&:line_item).map do |line_item, items|
|
|
102
|
+
Spree::Fulfillment::ManifestItem.new(line_item, line_item.variant, items.sum(&:quantity), nil)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Who is shipping it. More than one only where a marketplace's own
|
|
107
|
+
# warehouse ships several sellers' goods in one box.
|
|
108
|
+
#
|
|
109
|
+
# @return [Array<Spree::Seller>]
|
|
110
|
+
def sellers
|
|
111
|
+
fulfillments.filter_map { |fulfillment| fulfillment.order&.seller }.uniq
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Array<Spree::Order>]
|
|
115
|
+
def orders
|
|
116
|
+
fulfillments.filter_map(&:order).uniq
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The half that answers for the parcel. Every half carries the same origin,
|
|
122
|
+
# method and rate name — only the money was divided.
|
|
123
|
+
def primary
|
|
124
|
+
fulfillments.first
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -146,6 +146,15 @@ module Spree
|
|
|
146
146
|
code.upcase
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
# The code with all but its last four characters hidden — for a reader who
|
|
150
|
+
# may see which card paid for an order but must not be able to spend it.
|
|
151
|
+
#
|
|
152
|
+
# @return [String]
|
|
153
|
+
def masked_code
|
|
154
|
+
visible = display_code.last(4)
|
|
155
|
+
('*' * [display_code.length - visible.length, 0].max) + visible
|
|
156
|
+
end
|
|
157
|
+
|
|
149
158
|
# Checks if the gift card is expired
|
|
150
159
|
# @return [Boolean]
|
|
151
160
|
def expired?
|
|
@@ -51,7 +51,7 @@ module Spree
|
|
|
51
51
|
# Callbacks
|
|
52
52
|
#
|
|
53
53
|
after_initialize :set_defaults, if: :new_record?
|
|
54
|
-
before_validation :
|
|
54
|
+
before_validation :set_resource, if: :new_record?
|
|
55
55
|
before_validation :set_invitee_from_email, on: :create
|
|
56
56
|
after_commit :publish_invitation_created_event, on: :create, unless: :skip_email
|
|
57
57
|
|
|
@@ -97,10 +97,22 @@ module Spree
|
|
|
97
97
|
true
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
#
|
|
100
|
+
# The link that accepts this invitation. It carries the token, which is the
|
|
101
|
+
# only credential acceptance asks for, so hand it only to a caller who could
|
|
102
|
+
# have sent the invitation themselves.
|
|
103
|
+
#
|
|
104
|
+
# @return [String] an absolute URL when the app's origin is configured, otherwise a path
|
|
105
|
+
def acceptance_url
|
|
106
|
+
Rails.application.routes.url_helpers.admin_invitation_acceptance_url(self)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Resends the invitation email if the invitation is pending and not expired.
|
|
110
|
+
# The token is rotated first, so a link that leaked with the earlier email
|
|
111
|
+
# stops working.
|
|
101
112
|
def resend!
|
|
102
113
|
return if expired? || deleted? || accepted?
|
|
103
114
|
|
|
115
|
+
regenerate_token
|
|
104
116
|
publish_event('invitation.resent')
|
|
105
117
|
end
|
|
106
118
|
|
|
@@ -123,10 +135,11 @@ module Spree
|
|
|
123
135
|
# A role names what it governs, so the invitation follows it — one carrying
|
|
124
136
|
# another resource's role would grant access somewhere the inviter never
|
|
125
137
|
# named. Resolved at validation rather than on initialize, since a caller's
|
|
126
|
-
# own `resource` is not assigned yet when the record is instantiated.
|
|
127
|
-
|
|
138
|
+
# own `resource` is not assigned yet when the record is instantiated. The
|
|
139
|
+
# role is never defaulted: the inviter names it, and the controller checks
|
|
140
|
+
# they may grant it.
|
|
141
|
+
def set_resource
|
|
128
142
|
self.resource ||= role&.resource || Spree::Store.current
|
|
129
|
-
self.role ||= Spree::Role.default_admin_role(resource)
|
|
130
143
|
end
|
|
131
144
|
|
|
132
145
|
def invitee_is_not_inviter
|
|
@@ -50,7 +50,7 @@ module Spree
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def taken?(record, candidate)
|
|
53
|
-
record.class.unscoped.exists?(number: candidate)
|
|
53
|
+
record.class.base_class.unscoped.exists?(number: candidate)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def next_free_value(record, from)
|
|
@@ -58,7 +58,7 @@ module Spree
|
|
|
58
58
|
|
|
59
59
|
loop do
|
|
60
60
|
candidates = (value + 1..value + SCAN_BATCH_SIZE).index_by { |v| compose(record, v) }
|
|
61
|
-
taken = record.class.unscoped.where(number: candidates.keys).pluck(:number)
|
|
61
|
+
taken = record.class.base_class.unscoped.where(number: candidates.keys).pluck(:number)
|
|
62
62
|
free = (candidates.keys - taken).first
|
|
63
63
|
|
|
64
64
|
return candidates.fetch(free) if free
|
|
@@ -48,6 +48,13 @@ module Spree
|
|
|
48
48
|
distinct
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
# Option values are owned through their option type, and the store has no
|
|
52
|
+
# `option_values` association for `Spree::Base.for_store` to follow, which
|
|
53
|
+
# would otherwise fall back to every store's values.
|
|
54
|
+
scope :for_store, lambda { |store|
|
|
55
|
+
joins(:option_type).where(Spree::OptionType.table_name => { store_id: store.id })
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
scope :for_products, lambda { |products|
|
|
52
59
|
# we need to use map(&:id) to avoid SQL errors when merging with other scopes
|
|
53
60
|
joins(:variants).where(Spree::Variant.table_name => { product_id: products.map(&:id) })
|
data/app/models/spree/order.rb
CHANGED
|
@@ -170,6 +170,17 @@ module Spree
|
|
|
170
170
|
order_group_id po_number
|
|
171
171
|
]
|
|
172
172
|
self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search multi_search]
|
|
173
|
+
# A seller never sees the buyer's email, and a company member filtering the
|
|
174
|
+
# company's orders must not learn a colleague's; the risk flag and coupon
|
|
175
|
+
# are back-office data. `search` matches on the email too.
|
|
176
|
+
self.private_ransackable_attributes = {
|
|
177
|
+
store: %w[email considered_risky coupon_code],
|
|
178
|
+
seller: %w[email considered_risky coupon_code]
|
|
179
|
+
}
|
|
180
|
+
self.private_ransackable_scopes = {
|
|
181
|
+
store: %w[search multi_search],
|
|
182
|
+
seller: %w[search multi_search]
|
|
183
|
+
}
|
|
173
184
|
|
|
174
185
|
# Set to false on admin-initiated flows to suppress customer-facing emails.
|
|
175
186
|
attr_accessor :notify_customer
|
|
@@ -242,6 +253,11 @@ module Spree
|
|
|
242
253
|
# checkout names the one child being refunded. Keyed on that column rather
|
|
243
254
|
# than walked through payments, which a grouped order does not own.
|
|
244
255
|
has_many :refunds, class_name: 'Spree::Refund', inverse_of: :order, dependent: :nullify
|
|
256
|
+
# The other half of what this order gave back. A refund paid as store
|
|
257
|
+
# credit writes no Spree::Refund row, so anything measuring what the
|
|
258
|
+
# customer got back has to add the two ledgers.
|
|
259
|
+
has_many :store_credit_refunds, class_name: 'Spree::StoreCredit', inverse_of: :refunded_order,
|
|
260
|
+
foreign_key: :refunded_order_id, dependent: :nullify
|
|
245
261
|
|
|
246
262
|
# Typed adjustment rows owned by this order (line-, fulfillment- and
|
|
247
263
|
# order-level). See docs/plans/6.0-6.1-split-adjustments.md.
|
|
@@ -333,12 +349,8 @@ module Spree
|
|
|
333
349
|
scope :partially_shipped, -> { where(fulfillment_status: %w[partial]) }
|
|
334
350
|
scope :not_shipped, -> { where(fulfillment_status: %w[unfulfilled partial]) }
|
|
335
351
|
scope :shipped, -> { where(fulfillment_status: %w[fulfilled delivered shipped]) }
|
|
336
|
-
scope :refunded,
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
scope :partially_refunded, lambda {
|
|
340
|
-
joins(:refunds).group(:id).having("sum(#{Spree::Refund.table_name}.amount) < #{Spree::Order.table_name}.total")
|
|
341
|
-
}
|
|
352
|
+
scope :refunded, -> { where(payment_status: 'refunded') }
|
|
353
|
+
scope :partially_refunded, -> { where(payment_status: 'partially_refunded') }
|
|
342
354
|
scope :with_deleted_bill_address, -> { joins(:bill_address).where.not(Address.table_name => { deleted_at: nil }) }
|
|
343
355
|
scope :with_deleted_ship_address, -> { joins(:ship_address).where.not(Address.table_name => { deleted_at: nil }) }
|
|
344
356
|
|
|
@@ -746,12 +758,8 @@ module Spree
|
|
|
746
758
|
|
|
747
759
|
# What has been captured against this order, net of refunds.
|
|
748
760
|
#
|
|
749
|
-
#
|
|
750
|
-
#
|
|
751
|
-
# figure comes from its share of the group's payments instead, the same
|
|
752
|
-
# way {Spree::Orders::UpdateStatuses} derives +payment_status+. The
|
|
753
|
-
# group's own total will not do: once one seller has been captured and
|
|
754
|
-
# another has not, no proportion of it describes either.
|
|
761
|
+
# Read from the share rows rather than the +payment_total+ they are summed
|
|
762
|
+
# into, since a caller about to move money needs what they say now.
|
|
755
763
|
#
|
|
756
764
|
# @return [BigDecimal]
|
|
757
765
|
def net_captured_total
|
|
@@ -1248,6 +1256,21 @@ module Spree
|
|
|
1248
1256
|
|
|
1249
1257
|
private
|
|
1250
1258
|
|
|
1259
|
+
# An order placed in a split checkout owns no payments, so its money is
|
|
1260
|
+
# the sum of its shares of the group's instead — the same source
|
|
1261
|
+
# {Spree::Orders::UpdateStatuses} derives payment_status from.
|
|
1262
|
+
#
|
|
1263
|
+
# @return [Arel::Nodes::NamedFunction]
|
|
1264
|
+
def settled_payments_arel
|
|
1265
|
+
return super unless grouped?
|
|
1266
|
+
|
|
1267
|
+
splits = Spree::PaymentSplit.arel_table
|
|
1268
|
+
net = splits.project(splits[:captured_amount].sum - splits[:refunded_amount].sum).
|
|
1269
|
+
where(splits[:order_id].eq(id))
|
|
1270
|
+
|
|
1271
|
+
Arel::Nodes::NamedFunction.new('COALESCE', [Arel::Nodes::Grouping.new(net), Arel.sql('0')])
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1251
1274
|
def ensure_can_be_deleted
|
|
1252
1275
|
return true if can_be_deleted?
|
|
1253
1276
|
|
|
@@ -55,15 +55,23 @@ module Spree
|
|
|
55
55
|
has_many :payments, class_name: 'Spree::Payment', inverse_of: :order_group, dependent: :restrict_with_error
|
|
56
56
|
has_many :payment_splits, through: :payments, class_name: 'Spree::PaymentSplit', source: :payment_splits
|
|
57
57
|
|
|
58
|
+
# What the customer bought and the money hanging off it, reached through
|
|
59
|
+
# the children that own it. A purchase-level reader is not a second source
|
|
60
|
+
# of truth — it is the same rows, asked for as one basket, which is how the
|
|
61
|
+
# customer's own confirmation has to describe them.
|
|
62
|
+
has_many :line_items, through: :orders, class_name: 'Spree::LineItem', source: :line_items
|
|
63
|
+
has_many :fulfillments, through: :orders, class_name: 'Spree::Fulfillment', source: :fulfillments
|
|
64
|
+
has_many :discounts, through: :orders, class_name: 'Spree::Discount', source: :discounts
|
|
65
|
+
has_many :fees, through: :orders, class_name: 'Spree::Fee', source: :fees
|
|
66
|
+
|
|
58
67
|
# Brings the ship/bill pair with the shipping_address/billing_address
|
|
59
68
|
# aliases every other purchase surface reads them by.
|
|
60
69
|
#
|
|
61
70
|
# Only the associations and aliases apply here. The concern's checkout
|
|
62
|
-
# helpers — shipping_address_required?, assign_default_addresses! —
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# divided, so nothing asks it to work them out.
|
|
71
|
+
# helpers — shipping_address_required?, assign_default_addresses! — are
|
|
72
|
+
# about working an address out, and a group's addresses are copies made
|
|
73
|
+
# when the checkout divided, so nothing asks it to. They also read digital
|
|
74
|
+
# goods, which only a child can answer for.
|
|
67
75
|
include Spree::Purchase::Addresses
|
|
68
76
|
# What a marketplace asks about a group. Kept out of the class itself so
|
|
69
77
|
# this stays the neutral primitive its other consumers need.
|
|
@@ -81,7 +89,6 @@ module Spree
|
|
|
81
89
|
self.whitelisted_ransackable_associations = %w[orders customer]
|
|
82
90
|
|
|
83
91
|
extend Spree::DisplayMoney
|
|
84
|
-
money_methods :total, :item_total
|
|
85
92
|
|
|
86
93
|
# What the customer was charged for the whole checkout, and what it was
|
|
87
94
|
# charged for — the sums of what each child independently computed. Never
|
|
@@ -104,6 +111,9 @@ module Spree
|
|
|
104
111
|
end
|
|
105
112
|
end
|
|
106
113
|
|
|
114
|
+
money_methods(*ROLLED_UP_TOTALS)
|
|
115
|
+
money_methods :gift_card_total
|
|
116
|
+
|
|
107
117
|
alias ship_total delivery_total
|
|
108
118
|
|
|
109
119
|
# Where the checkout was made from, carried on the children because they
|
|
@@ -114,6 +124,68 @@ module Spree
|
|
|
114
124
|
orders.first&.last_ip_address
|
|
115
125
|
end
|
|
116
126
|
|
|
127
|
+
# @return [String, nil]
|
|
128
|
+
def locale
|
|
129
|
+
orders.first&.locale
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [String, nil]
|
|
133
|
+
def po_number
|
|
134
|
+
orders.first&.po_number
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Who to address the purchase to, read off the addresses the checkout
|
|
138
|
+
# copied onto the group — the same answer +Spree::Order#name+ gives, so a
|
|
139
|
+
# customer-facing document greets them identically either way.
|
|
140
|
+
#
|
|
141
|
+
# @return [String, nil]
|
|
142
|
+
def name
|
|
143
|
+
(bill_address || ship_address)&.full_name
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The parcels this purchase actually ships in — not one per child order.
|
|
147
|
+
# A parcel divided between sellers is counted once, because one box leaves
|
|
148
|
+
# the warehouse however many orders its contents belong to. See
|
|
149
|
+
# +Spree::FulfillmentGroup+ for why the two numbers differ.
|
|
150
|
+
#
|
|
151
|
+
# Ordered by child, so first-party goods lead and sellers follow in the
|
|
152
|
+
# order the split filed them, and the same purchase reads the same way
|
|
153
|
+
# twice.
|
|
154
|
+
#
|
|
155
|
+
# @return [Array<Spree::FulfillmentGroup>]
|
|
156
|
+
def fulfillment_groups
|
|
157
|
+
Spree::FulfillmentGroup.build_from(
|
|
158
|
+
fulfillments.
|
|
159
|
+
includes(:stock_location, { order: :seller }, { selected_delivery_rate: :delivery_method }).
|
|
160
|
+
order(:order_id, :id)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# What was bought that no parcel carries — a downloaded album, a gift card
|
|
165
|
+
# sent by email. Without it a confirmation organised by parcel would list
|
|
166
|
+
# everything the customer is waiting for and silently omit what they
|
|
167
|
+
# already have.
|
|
168
|
+
#
|
|
169
|
+
# @param groups [Array<Spree::FulfillmentGroup>] the parcels, when the
|
|
170
|
+
# caller has already built them — rebuilding re-reads the fulfillments
|
|
171
|
+
# @return [Array<Spree::LineItem>]
|
|
172
|
+
def unfulfilled_line_items(groups = fulfillment_groups)
|
|
173
|
+
line_items.to_a - groups.flat_map(&:line_items)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# What the customer paid with a gift card. Not rolled up from the children:
|
|
177
|
+
# the group claims the payments at completion, so a child's own
|
|
178
|
+
# +gift_card_total+ — which reads its payments — answers zero for every one
|
|
179
|
+
# of them, and the confirmation would omit a deduction its total makes.
|
|
180
|
+
#
|
|
181
|
+
# @return [BigDecimal]
|
|
182
|
+
def gift_card_total
|
|
183
|
+
return 0.to_d if gift_card.nil?
|
|
184
|
+
|
|
185
|
+
store_credit_ids = payments.store_credits.valid.pluck(:source_id)
|
|
186
|
+
Spree::StoreCredit.where(id: store_credit_ids, originator: gift_card).sum(:amount)
|
|
187
|
+
end
|
|
188
|
+
|
|
117
189
|
# @return [String, nil] the group's fulfillment position, in the same
|
|
118
190
|
# vocabulary a single order uses, rolled up across children
|
|
119
191
|
def fulfillment_status
|
|
@@ -33,6 +33,12 @@ module Spree
|
|
|
33
33
|
validates :currency, presence: true
|
|
34
34
|
validates :order_id, uniqueness: { scope: [:payment_id, *spree_base_uniqueness_scope] }
|
|
35
35
|
|
|
36
|
+
#
|
|
37
|
+
# Callbacks
|
|
38
|
+
#
|
|
39
|
+
after_save :refresh_order_payment_total, if: :settled_money_moved?
|
|
40
|
+
after_destroy :refresh_order_payment_total, if: :held_settled_money?
|
|
41
|
+
|
|
36
42
|
#
|
|
37
43
|
# Scopes
|
|
38
44
|
#
|
|
@@ -86,5 +92,23 @@ module Spree
|
|
|
86
92
|
def capture_in_flight?
|
|
87
93
|
claimed_amount.positive?
|
|
88
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Here rather than at each of the five writers that move a share, because a
|
|
99
|
+
# column refreshed at five call sites will eventually be refreshed at four.
|
|
100
|
+
def refresh_order_payment_total
|
|
101
|
+
order&.refresh_payment_total!
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reserving money or moving an authorisation reports no differently, and
|
|
105
|
+
# locking the order for it would invert {Spree::Payment#carry_splits_to}.
|
|
106
|
+
def settled_money_moved?
|
|
107
|
+
saved_change_to_captured_amount? || saved_change_to_refunded_amount?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def held_settled_money?
|
|
111
|
+
captured_amount.positive? || refunded_amount.positive?
|
|
112
|
+
end
|
|
89
113
|
end
|
|
90
114
|
end
|
data/app/models/spree/price.rb
CHANGED
|
@@ -56,6 +56,7 @@ module Spree
|
|
|
56
56
|
numericality: { only_integer: true, greater_than: 0 }
|
|
57
57
|
validate :break_requires_price_list
|
|
58
58
|
validate :breaks_within_cap
|
|
59
|
+
validate :ladder_does_not_rise
|
|
59
60
|
|
|
60
61
|
scope :with_currency, ->(currency) { where(currency: currency) }
|
|
61
62
|
scope :non_zero, -> { where.not(amount: [nil, 0]) }
|
|
@@ -229,6 +230,31 @@ module Spree
|
|
|
229
230
|
variant&.product&.store
|
|
230
231
|
end
|
|
231
232
|
|
|
233
|
+
# The first rung of `[[quantity, amount], ...]` costing more than the
|
|
234
|
+
# quantity below it buys — the rung beneath, or `floor` for a ladder
|
|
235
|
+
# starting above one unit. Returned as `[quantity, amount, floor]`.
|
|
236
|
+
#
|
|
237
|
+
# @param ladder [Array<Array>] rungs by quantity
|
|
238
|
+
# @param floor [BigDecimal, nil]
|
|
239
|
+
# @return [Array, nil]
|
|
240
|
+
def self.rising_rung(ladder, floor)
|
|
241
|
+
bottom_quantity, bottom_amount = ladder.first
|
|
242
|
+
return if bottom_quantity.nil?
|
|
243
|
+
|
|
244
|
+
# The bottom rung answers to the floor, and is checked first so the rung
|
|
245
|
+
# named is the first breach reading upward — a merchant who fixes what
|
|
246
|
+
# they are told to fix should not meet a second error beneath it.
|
|
247
|
+
if bottom_quantity != 1 && floor.present? && bottom_amount > floor
|
|
248
|
+
return [bottom_quantity, bottom_amount, floor]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
ladder.each_cons(2) do |(_, below), (quantity, amount)|
|
|
252
|
+
return [quantity, amount, below] if amount > below
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
232
258
|
# Whether this row is a quantity break rather than the list's ordinary
|
|
233
259
|
# price for the variant.
|
|
234
260
|
# @return [Boolean]
|
|
@@ -268,6 +294,54 @@ module Spree
|
|
|
268
294
|
errors.add(:min_quantity, :too_many_breaks, count: MAXIMUM_BREAKS_PER_VARIANT)
|
|
269
295
|
end
|
|
270
296
|
|
|
297
|
+
# A break promises a better price for a bigger order, so no rung may cost
|
|
298
|
+
# more than the quantity below it buys. Judged over the ladder this save
|
|
299
|
+
# would leave behind rather than this row alone: lowering one rung strands
|
|
300
|
+
# every rung above it, and a base price is the floor each ladder falls
|
|
301
|
+
# through to. The bulk path judges the same way; this covers the
|
|
302
|
+
# single-row writers, as the break cap is covered on both
|
|
303
|
+
# (docs/plans/6.0-volume-pricing.md).
|
|
304
|
+
def ladder_does_not_rise
|
|
305
|
+
return if amount.nil? || variant_id.blank? || currency.blank?
|
|
306
|
+
return unless new_record? || will_save_change_to_attribute?(:amount) || will_save_change_to_attribute?(:min_quantity)
|
|
307
|
+
|
|
308
|
+
breach = affected_ladders.lazy.filter_map { |list_id, floor| self.class.rising_rung(resulting_ladder(list_id), floor) }.first
|
|
309
|
+
return if breach.nil?
|
|
310
|
+
|
|
311
|
+
errors.add(:amount, :leaves_a_rising_ladder,
|
|
312
|
+
quantity: breach[0],
|
|
313
|
+
amount: Spree::Money.new(breach[1], currency: currency),
|
|
314
|
+
floor: Spree::Money.new(breach[2], currency: currency))
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The ladders this row can disturb, each with the floor it falls through
|
|
318
|
+
# to: its own list for a break, and every ladder the variant carries for a
|
|
319
|
+
# base price, since that is the floor they all read.
|
|
320
|
+
#
|
|
321
|
+
# @return [Array<Array>] `[[price_list_id, floor], ...]`
|
|
322
|
+
def affected_ladders
|
|
323
|
+
if price_list_id.present?
|
|
324
|
+
base = self.class.base_prices.where(variant_id: variant_id, currency: currency).
|
|
325
|
+
where.not(amount: nil).pick(:amount)
|
|
326
|
+
return [[price_list_id, base]]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
self.class.where(variant_id: variant_id, currency: currency).
|
|
330
|
+
where.not(price_list_id: nil).where.not(amount: nil).
|
|
331
|
+
distinct.pluck(:price_list_id).map { |list_id| [list_id, amount] }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# One list's rungs as this save would leave them, by quantity.
|
|
335
|
+
#
|
|
336
|
+
# @return [Array<Array>] `[[quantity, amount], ...]`
|
|
337
|
+
def resulting_ladder(list_id)
|
|
338
|
+
rows = self.class.where(variant_id: variant_id, currency: currency, price_list_id: list_id).where.not(amount: nil)
|
|
339
|
+
rows = rows.where.not(id: id) if persisted?
|
|
340
|
+
rungs = rows.pluck(:min_quantity, :amount).to_h
|
|
341
|
+
rungs[min_quantity] = amount if price_list_id == list_id
|
|
342
|
+
rungs.sort_by(&:first)
|
|
343
|
+
end
|
|
344
|
+
|
|
271
345
|
def should_record_price_history?
|
|
272
346
|
price_list_id.nil? &&
|
|
273
347
|
amount.present? &&
|
data/app/models/spree/product.rb
CHANGED
|
@@ -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
|