spree_core 6.0.0.beta2 → 6.0.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
  3. data/app/jobs/spree/sample_data/load_job.rb +7 -2
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
  5. data/app/mailers/spree/admin_user_mailer.rb +2 -2
  6. data/app/models/concerns/spree/customer_methods.rb +17 -1
  7. data/app/models/concerns/spree/has_number.rb +3 -2
  8. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  9. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  10. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  11. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  12. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  13. data/app/models/spree/adjusters/promotion.rb +1 -1
  14. data/app/models/spree/authentication/lockout.rb +34 -0
  15. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  16. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  17. data/app/models/spree/claim.rb +1 -0
  18. data/app/models/spree/commission_line.rb +4 -2
  19. data/app/models/spree/commission_rule.rb +1 -1
  20. data/app/models/spree/coupon_code.rb +14 -2
  21. data/app/models/spree/current.rb +15 -0
  22. data/app/models/spree/exchange.rb +1 -0
  23. data/app/models/spree/export.rb +8 -5
  24. data/app/models/spree/exports/customers.rb +2 -2
  25. data/app/models/spree/fulfillment_group.rb +127 -0
  26. data/app/models/spree/gift_card.rb +9 -0
  27. data/app/models/spree/invitation.rb +18 -5
  28. data/app/models/spree/number_generators/sequential.rb +2 -2
  29. data/app/models/spree/option_value.rb +7 -0
  30. data/app/models/spree/order.rb +35 -12
  31. data/app/models/spree/order_group.rb +78 -6
  32. data/app/models/spree/payment_source.rb +1 -1
  33. data/app/models/spree/payment_split.rb +24 -0
  34. data/app/models/spree/price.rb +74 -0
  35. data/app/models/spree/product.rb +4 -0
  36. data/app/models/spree/product_option_type.rb +11 -0
  37. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  38. data/app/models/spree/promotion/rules/category.rb +14 -9
  39. data/app/models/spree/promotion/rules/product.rb +17 -1
  40. data/app/models/spree/promotion.rb +1 -1
  41. data/app/models/spree/promotion_action.rb +12 -1
  42. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  43. data/app/models/spree/promotion_rule.rb +14 -0
  44. data/app/models/spree/return.rb +16 -6
  45. data/app/models/spree/return_line_item.rb +19 -0
  46. data/app/models/spree/search_provider/database.rb +10 -2
  47. data/app/models/spree/seller_payout.rb +3 -1
  48. data/app/models/spree/seller_transfer.rb +3 -1
  49. data/app/models/spree/store_credit.rb +4 -0
  50. data/app/models/spree/translations/batch.rb +4 -2
  51. data/app/models/spree/user_identity.rb +10 -6
  52. data/app/models/spree/variant.rb +8 -3
  53. data/app/models/spree/webhook_delivery.rb +28 -0
  54. data/app/models/spree/webhook_endpoint.rb +14 -0
  55. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  56. data/app/services/spree/carts/split_by_seller.rb +22 -12
  57. data/app/services/spree/companies/add_member.rb +8 -2
  58. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  59. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  60. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  61. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  62. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  63. data/app/services/spree/orders/update_statuses.rb +8 -2
  64. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  65. data/app/services/spree/sample_data/import_builder.rb +1 -1
  66. data/app/services/spree/sample_data/loader.rb +54 -40
  67. data/app/services/spree/seeds/all.rb +1 -26
  68. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  69. data/app/services/spree/seeds/api_keys.rb +10 -9
  70. data/app/services/spree/seeds/channels.rb +9 -8
  71. data/app/services/spree/seeds/commission_rates.rb +21 -20
  72. data/app/services/spree/seeds/customer_groups.rb +5 -4
  73. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  74. data/app/services/spree/seeds/payment_methods.rb +12 -11
  75. data/app/services/spree/seeds/product_types.rb +7 -8
  76. data/app/services/spree/seeds/returns_environment.rb +12 -11
  77. data/app/services/spree/seeds/roles.rb +8 -7
  78. data/app/services/spree/seeds/saved_reports.rb +19 -20
  79. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  80. data/app/services/spree/seeds/store_resources.rb +41 -0
  81. data/app/services/spree/seeds/store_scoped.rb +15 -0
  82. data/app/services/spree/seeds/tax_categories.rb +10 -9
  83. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  84. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  85. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  86. data/app/workflows/spree/carts/add_item.rb +5 -7
  87. data/app/workflows/spree/carts/complete.rb +20 -64
  88. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  89. data/app/workflows/spree/claims/create.rb +31 -1
  90. data/app/workflows/spree/claims/resolve.rb +7 -11
  91. data/app/workflows/spree/exchanges/create.rb +11 -0
  92. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  93. data/app/workflows/spree/orders/complete.rb +99 -2
  94. data/app/workflows/spree/price_lists/update.rb +17 -0
  95. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  96. data/app/workflows/spree/returns/create.rb +9 -15
  97. data/app/workflows/spree/returns/refund.rb +15 -24
  98. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  99. data/app/workflows/spree/sellers/invite.rb +1 -1
  100. data/config/locales/en.yml +7 -119
  101. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  102. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  103. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  104. data/db/sample_data/channels.rb +1 -1
  105. data/db/sample_data/collections.rb +1 -1
  106. data/db/sample_data/custom_field_definitions.rb +1 -1
  107. data/db/sample_data/digital_products.rb +1 -1
  108. data/db/sample_data/fulfillment.rb +1 -1
  109. data/db/sample_data/options.rb +4 -2
  110. data/db/sample_data/orders.rb +9 -10
  111. data/db/sample_data/payment_methods.rb +18 -18
  112. data/db/sample_data/product_type_categories.rb +1 -1
  113. data/db/sample_data/product_types.rb +2 -2
  114. data/db/sample_data/promotions.rb +2 -4
  115. data/db/sample_data/wholesale.rb +1 -1
  116. data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
  117. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
  118. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  119. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  120. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  121. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  122. data/lib/spree/core/engine.rb +8 -8
  123. data/lib/spree/core/version.rb +1 -1
  124. data/lib/spree/iso_data.rb +23 -4
  125. data/lib/spree/store_scope_guard.rb +16 -1
  126. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  127. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  128. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  129. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  130. data/lib/tasks/sample_data.rake +8 -2
  131. data/lib/tasks/upgrade.rake +6 -21
  132. metadata +13 -4
@@ -3,8 +3,8 @@ module Spree
3
3
  class Customers < Spree::Export
4
4
  def scope_includes
5
5
  [
6
- { bill_address: :state },
7
- { ship_address: :state },
6
+ :bill_address,
7
+ :ship_address,
8
8
  { custom_fields: :custom_field_definition }
9
9
  ]
10
10
  end
@@ -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 :set_role_and_resource, if: :new_record?
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
- # Resends the invitation email if the invitation is pending and not expired
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
- def set_role_and_resource
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) })
@@ -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, lambda {
337
- joins(:refunds).group(:id).having("sum(#{Spree::Refund.table_name}.amount) = #{Spree::Order.table_name}.total")
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
- # An order placed in a split checkout owns no payments, so its own
750
- # +payment_total+ stays at zero however much the customer paid — the
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! — reach
63
- # for line items, fulfillments and digital goods, none of which a group
64
- # has: they belong to its children, which answer those questions for
65
- # themselves. A group's addresses are copies made when the checkout
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
@@ -1,7 +1,7 @@
1
1
  # This model is used to store payment sources for non-credit card payments, eg wallet, account, etc.
2
2
  module Spree
3
3
  class PaymentSource < Spree.base_class
4
- has_prefix_id :ps
4
+ has_prefix_id :psrc
5
5
 
6
6
  include Spree::HasCustomFields
7
7
  include Spree::Metadata
@@ -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
@@ -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? &&
@@ -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