spree_core 6.0.0.beta1 → 6.0.0.beta3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
- data/app/jobs/spree/sample_data/load_job.rb +18 -0
- 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/acted_by.rb +210 -0
- data/app/models/concerns/spree/actor.rb +51 -0
- data/app/models/concerns/spree/admin_user_methods.rb +48 -17
- 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/payment_processing.rb +1 -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/api_key.rb +5 -3
- 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/catalog.rb +50 -13
- data/app/models/spree/claim.rb +3 -1
- 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 +56 -13
- data/app/models/spree/exchange.rb +3 -1
- 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/line_item.rb +1 -1
- 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 +89 -18
- 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/pricing_provider/internal.rb +37 -15
- 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/refund.rb +2 -1
- data/app/models/spree/return.rb +18 -7
- 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/stock_receipt.rb +7 -2
- data/app/models/spree/store.rb +4 -0
- 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/approve.rb +3 -2
- 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/cancel.rb +3 -2
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/create.rb +1 -1
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/approve.rb +1 -1
- data/app/workflows/spree/returns/create.rb +11 -17
- data/app/workflows/spree/returns/refund.rb +16 -25
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +8 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -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 +121 -6
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
- data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
- 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/api_resource/templates/store_controller_spec.rb.tt +4 -1
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/model/model_generator.rb +40 -1
- data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
- data/lib/generators/spree/model/templates/model.rb.tt +10 -4
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/configuration.rb +25 -25
- data/lib/spree/core/engine.rb +35 -8
- data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -0
- 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/testing_support/factories/user_factory.rb +0 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/backfill_actor_types.rake +35 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/store_settings.rake +1 -0
- data/lib/tasks/upgrade.rake +6 -21
- metadata +23 -9
- /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
- /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
- /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
- /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
- /data/{app/models → lib}/spree/checkout/step.rb +0 -0
|
@@ -45,7 +45,12 @@ module Spree
|
|
|
45
45
|
# back to base.
|
|
46
46
|
# @return [Spree::Price]
|
|
47
47
|
def find_price_from_lists
|
|
48
|
-
|
|
48
|
+
catalog_price_list_groups.each do |price_lists|
|
|
49
|
+
price = best_price_among(price_lists)
|
|
50
|
+
return price if price
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
applicable_price_lists.each do |price_list|
|
|
49
54
|
price = find_price_for_list(price_list)
|
|
50
55
|
return price if price
|
|
51
56
|
end
|
|
@@ -53,12 +58,25 @@ module Spree
|
|
|
53
58
|
nil
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
61
|
+
# The cheapest price one node's lists give, nil when none prices this
|
|
62
|
+
# variant. Catalogs sharing a node rank equally, so taking the first to
|
|
63
|
+
# answer charged a company from whichever sorted first rather than the
|
|
64
|
+
# cheaper (docs/plans/6.0-b2b-companies-and-catalogs.md). Equal amounts
|
|
65
|
+
# keep list order, so the answer is stable.
|
|
66
|
+
#
|
|
67
|
+
# @param price_lists [Array<Spree::PriceList>]
|
|
68
|
+
# @return [Spree::Price, nil]
|
|
69
|
+
def best_price_among(price_lists)
|
|
70
|
+
price_lists.filter_map { |price_list| find_price_for_list(price_list) }.min_by(&:amount)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Price lists reached through the buyer's effective catalogs, grouped
|
|
74
|
+
# by the node that assigns them and ordered nearest node first — the
|
|
75
|
+
# company subtree, or failing that the customer's groups, or failing
|
|
76
|
+
# that the channel's default catalog. The same chain visibility uses,
|
|
77
|
+
# and its steps are alternatives: a company buyer is priced by their
|
|
78
|
+
# company's agreement and never also by their customer group's
|
|
79
|
+
# ({Spree::Catalog.groups_for_context}).
|
|
62
80
|
#
|
|
63
81
|
# A catalog-attached list applies because the catalog applies: its
|
|
64
82
|
# audience rules are not consulted, since the assignment already
|
|
@@ -68,10 +86,13 @@ module Spree
|
|
|
68
86
|
# volume pricing is (docs/plans/6.0-price-list-automatic-pricing.md).
|
|
69
87
|
# Status and dates gate the list as before.
|
|
70
88
|
#
|
|
71
|
-
# @return [Array<Spree::PriceList
|
|
72
|
-
def
|
|
73
|
-
@
|
|
74
|
-
price_list.
|
|
89
|
+
# @return [Array<Array<Spree::PriceList>>]
|
|
90
|
+
def catalog_price_list_groups
|
|
91
|
+
@catalog_price_list_groups ||= catalog_groups_for_context.filter_map do |catalogs|
|
|
92
|
+
lists = catalogs.filter_map(&:price_list).select do |price_list|
|
|
93
|
+
price_list.currently_active? && price_list.contextual_rules_applicable?(context)
|
|
94
|
+
end
|
|
95
|
+
lists.presence
|
|
75
96
|
end
|
|
76
97
|
end
|
|
77
98
|
|
|
@@ -89,11 +110,12 @@ module Spree
|
|
|
89
110
|
# Catalogs that apply to this buyer, through the one entry point
|
|
90
111
|
# visibility and quantity terms also use — what a buyer is shown, what
|
|
91
112
|
# they pay and how much they must take have to come from the same
|
|
92
|
-
# agreement.
|
|
113
|
+
# agreement. Grouped by assigning node, since that is where precedence
|
|
114
|
+
# lives. Reuses the request-scoped set for the current store, so a
|
|
93
115
|
# listing does not re-resolve the same buyer per variant.
|
|
94
|
-
# @return [Array<Spree::Catalog
|
|
95
|
-
def
|
|
96
|
-
Spree::Catalog.
|
|
116
|
+
# @return [Array<Array<Spree::Catalog>>]
|
|
117
|
+
def catalog_groups_for_context
|
|
118
|
+
Spree::Catalog.groups_for_buyer(
|
|
97
119
|
store: context.store, customer: context.user,
|
|
98
120
|
company: context.company, channel: context.channel
|
|
99
121
|
)
|
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
|
|
@@ -19,7 +19,33 @@ module Spree
|
|
|
19
19
|
|
|
20
20
|
delegate :eligible?, to: :promotion
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# @return [Symbol]
|
|
23
|
+
def discount_scope
|
|
24
|
+
:line_item
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The gift is rarely the product the rules name ("buy a coffee maker,
|
|
28
|
+
# get a free tote"), so the rules decide whether the promotion applies
|
|
29
|
+
# and this action decides which lines it pays for.
|
|
30
|
+
#
|
|
31
|
+
# @param order [Spree::Order, Spree::Cart]
|
|
32
|
+
# @param line_item [Spree::LineItem]
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def applies_to_line_item?(order, line_item)
|
|
35
|
+
gifted_item?(line_item) && qualifies_beyond_the_gift?(order)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Covers the gifted units of the line at their own price, leaving any
|
|
39
|
+
# further units of the same variant the shopper bought themselves to be
|
|
40
|
+
# paid for.
|
|
41
|
+
#
|
|
42
|
+
# @param line_item [Spree::LineItem]
|
|
43
|
+
# @return [BigDecimal] never positive
|
|
44
|
+
def compute_amount(line_item)
|
|
45
|
+
line_item.price * gifted_quantity_of(line_item) * -1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Tops the order up to the promised quantity and discounts it.
|
|
23
49
|
#
|
|
24
50
|
# This doesn't play right with Add to Cart events because at the moment
|
|
25
51
|
# the item was added to cart the promo may not be eligible. However it
|
|
@@ -32,46 +58,26 @@ module Spree
|
|
|
32
58
|
# - Customer increases item quantity to 5 (order total goes to $50)
|
|
33
59
|
# - Now the order is eligible for the promo and the action should perform
|
|
34
60
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# e.g.
|
|
39
|
-
# - Promo adds 1 item A to cart if order total greater then $30
|
|
40
|
-
# - Customer add 2 items B to cart, current order total is $40
|
|
41
|
-
# - This action performs adding item A to cart since order is eligible
|
|
42
|
-
# - Customer changes his mind and updates item B quantity to 1
|
|
43
|
-
# - At this point order is no longer eligible and one might expect
|
|
44
|
-
# that item A should be removed
|
|
45
|
-
#
|
|
46
|
-
# It doesn't remove items from the order here because there's no way
|
|
47
|
-
# it can know whether that item was added via this promo action or if
|
|
48
|
-
# it was manually populated somewhere else. In that case the item
|
|
49
|
-
# needs to be manually removed from the order by the customer
|
|
61
|
+
# A shopper who already has the gift variant is given theirs free rather
|
|
62
|
+
# than a duplicate, so the promotion still applies when it has nothing
|
|
63
|
+
# to add.
|
|
50
64
|
def perform(options = {})
|
|
51
65
|
order = options[:order]
|
|
52
66
|
return unless eligible? order
|
|
67
|
+
return unless qualifies_beyond_the_gift?(order)
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
promotion_action_line_items.each do |item|
|
|
56
|
-
current_quantity = order.quantity_of(item.variant)
|
|
57
|
-
next unless current_quantity < item.quantity && item_available?(item)
|
|
69
|
+
added = add_missing_line_items(order)
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
line_item = add_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order },
|
|
61
|
-
variant: item.variant,
|
|
62
|
-
quantity: item.quantity - current_quantity).value
|
|
63
|
-
action_taken = true if line_item.try(:valid?)
|
|
64
|
-
end
|
|
65
|
-
action_taken
|
|
71
|
+
apply_via_adjuster(options) || added
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
# Called by promotion handler when a promotion is removed
|
|
69
75
|
# This will find any line item matching the ones defined in the PromotionAction
|
|
70
76
|
# and remove the same quantity as was added by the PromotionAction.
|
|
71
|
-
# Should help to prevent some of cases listed above the #perform method
|
|
72
77
|
def revert(options = {})
|
|
73
78
|
order = options[:order]
|
|
74
79
|
return if eligible?(order)
|
|
80
|
+
return unless order.promotions.include?(promotion)
|
|
75
81
|
|
|
76
82
|
action_taken = false
|
|
77
83
|
promotion_action_line_items.each do |item|
|
|
@@ -89,13 +95,69 @@ module Spree
|
|
|
89
95
|
end
|
|
90
96
|
|
|
91
97
|
# Checks that there's enough stock to add the line item to the order
|
|
92
|
-
|
|
98
|
+
#
|
|
99
|
+
# @param item [Spree::PromotionActionLineItem]
|
|
100
|
+
# @param quantity [Integer] units wanted, defaulting to the whole gift
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def item_available?(item, quantity = item.quantity)
|
|
93
103
|
quantifier = Spree::Stock::Quantifier.new(item.variant)
|
|
94
|
-
quantifier.can_supply?
|
|
104
|
+
quantifier.can_supply? quantity
|
|
95
105
|
end
|
|
96
106
|
|
|
97
107
|
private
|
|
98
108
|
|
|
109
|
+
# Whether this line holds a variant the promotion gives away.
|
|
110
|
+
def gifted_item?(line_item)
|
|
111
|
+
gifted_quantity_of(line_item).positive?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A gift cannot be the thing that qualifies the order for the promotion
|
|
115
|
+
# giving it away: a rule naming the gift's own product would otherwise
|
|
116
|
+
# hand the item over to anyone who put it in their cart. The order has
|
|
117
|
+
# to hold a line the rules count with units this action is not already
|
|
118
|
+
# covering. A promotion with no rules qualifies on anything, so there is
|
|
119
|
+
# nothing for the gift to stand in for.
|
|
120
|
+
def qualifies_beyond_the_gift?(order)
|
|
121
|
+
return true if promotion.promotion_rules.empty?
|
|
122
|
+
|
|
123
|
+
order.line_items.any? do |line_item|
|
|
124
|
+
promotion.line_item_actionable?(order, line_item) &&
|
|
125
|
+
gifted_quantity_of(line_item) < line_item.quantity
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# How many units of this line the promotion pays for — never more than
|
|
130
|
+
# the line holds, so a shopper buying three of a variant gifted once
|
|
131
|
+
# still pays for two. `quantity` is nullable and written by `upsert_all`,
|
|
132
|
+
# which skips validation, so a missing or negative one gifts nothing
|
|
133
|
+
# rather than raising or turning the discount into a surcharge.
|
|
134
|
+
#
|
|
135
|
+
# @param line_item [Spree::LineItem]
|
|
136
|
+
# @return [Integer]
|
|
137
|
+
def gifted_quantity_of(line_item)
|
|
138
|
+
gifted = promotion_action_line_items.detect { |item| item.variant_id == line_item.variant_id }
|
|
139
|
+
return 0 if gifted.nil?
|
|
140
|
+
|
|
141
|
+
[[line_item.quantity, gifted.quantity.to_i].min, 0].max
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def add_missing_line_items(order)
|
|
145
|
+
added_results = promotion_action_line_items.map do |item|
|
|
146
|
+
missing = item.quantity.to_i - order.quantity_of(item.variant)
|
|
147
|
+
next false unless missing.positive? && item_available?(item, missing)
|
|
148
|
+
|
|
149
|
+
add_service = order.is_a?(Spree::Cart) ? Spree.cart_add_item_workflow : Spree.order_add_item_service
|
|
150
|
+
result = add_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order },
|
|
151
|
+
variant: item.variant,
|
|
152
|
+
quantity: missing)
|
|
153
|
+
result.success?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
order.line_items.reload if added_results.any?
|
|
157
|
+
|
|
158
|
+
added_results.any?
|
|
159
|
+
end
|
|
160
|
+
|
|
99
161
|
def promotion_variants_scope
|
|
100
162
|
promotion.store.variants
|
|
101
163
|
end
|
|
@@ -21,18 +21,16 @@ module Spree
|
|
|
21
21
|
'category'
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# another store raises rather than quietly linking — the promotion
|
|
29
|
-
# would otherwise discount against a catalog its store cannot see.
|
|
24
|
+
# Resolved through the promotion's own store, prefixed and raw ids
|
|
25
|
+
# alike, so an id belonging to another store raises rather than
|
|
26
|
+
# quietly linking — the promotion would otherwise discount against a
|
|
27
|
+
# catalog its store cannot see.
|
|
30
28
|
def category_ids=(ids)
|
|
31
|
-
super(
|
|
32
|
-
Spree::PrefixedId.prefixed_id?(id) ? promotion_categories_scope.find_by_param!(id).id : id
|
|
33
|
-
end)
|
|
29
|
+
super(ids_within_store(ids, promotion && promotion_categories_scope))
|
|
34
30
|
end
|
|
35
31
|
|
|
32
|
+
validate :categories_belong_to_store
|
|
33
|
+
|
|
36
34
|
#
|
|
37
35
|
# Preferences
|
|
38
36
|
#
|
|
@@ -140,6 +138,13 @@ module Spree
|
|
|
140
138
|
promotion.store.categories
|
|
141
139
|
end
|
|
142
140
|
|
|
141
|
+
def categories_belong_to_store
|
|
142
|
+
return if promotion.nil? || category_ids.empty?
|
|
143
|
+
return if (category_ids - promotion_categories_scope.where(id: category_ids).ids).empty?
|
|
144
|
+
|
|
145
|
+
errors.add(:categories, :invalid)
|
|
146
|
+
end
|
|
147
|
+
|
|
143
148
|
# IDs of categories in rule including all their children
|
|
144
149
|
def eligible_category_ids_including_children
|
|
145
150
|
@eligible_category_ids_including_children ||= begin
|
|
@@ -15,6 +15,15 @@ module Spree
|
|
|
15
15
|
|
|
16
16
|
self.additional_permitted_attributes = [product_ids: []]
|
|
17
17
|
|
|
18
|
+
validate :products_belong_to_store
|
|
19
|
+
|
|
20
|
+
# Resolved through the promotion's own store, so another store's
|
|
21
|
+
# product raises rather than being linked and then served back with
|
|
22
|
+
# its admin data.
|
|
23
|
+
def product_ids=(ids)
|
|
24
|
+
super(ids_within_store(ids, promotion&.store&.products))
|
|
25
|
+
end
|
|
26
|
+
|
|
18
27
|
#
|
|
19
28
|
# Preferences
|
|
20
29
|
#
|
|
@@ -86,11 +95,18 @@ module Spree
|
|
|
86
95
|
ActiveSupport::Deprecation.warn(
|
|
87
96
|
'Please use `product_ids=` instead.'
|
|
88
97
|
)
|
|
89
|
-
self.product_ids = s
|
|
98
|
+
self.product_ids = s.to_s.split(',')
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
private
|
|
93
102
|
|
|
103
|
+
def products_belong_to_store
|
|
104
|
+
return if promotion.nil? || product_ids.empty?
|
|
105
|
+
return if (product_ids - promotion.store.products.where(id: product_ids).ids).empty?
|
|
106
|
+
|
|
107
|
+
errors.add(:products, :invalid)
|
|
108
|
+
end
|
|
109
|
+
|
|
94
110
|
def add_products
|
|
95
111
|
return if product_ids_to_add.nil?
|
|
96
112
|
|
|
@@ -37,6 +37,17 @@ module Spree
|
|
|
37
37
|
false
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Whether this action's line-level discount may land on +line_item+.
|
|
41
|
+
# Promotion rules narrow it by default; an action that creates its own
|
|
42
|
+
# lines answers for itself.
|
|
43
|
+
#
|
|
44
|
+
# @param order [Spree::Order, Spree::Cart]
|
|
45
|
+
# @param line_item [Spree::LineItem]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def applies_to_line_item?(order, line_item)
|
|
48
|
+
promotion.line_item_actionable?(order, line_item)
|
|
49
|
+
end
|
|
50
|
+
|
|
40
51
|
# Returns true if the promotion action is a free shipping action
|
|
41
52
|
#
|
|
42
53
|
# @return [Boolean]
|
|
@@ -53,7 +64,7 @@ module Spree
|
|
|
53
64
|
def self.types_for_discount_scope(scope)
|
|
54
65
|
case scope
|
|
55
66
|
when :line_item
|
|
56
|
-
%w[Spree::Promotion::Actions::CreateItemAdjustments Spree::Promotion::Actions::
|
|
67
|
+
%w[Spree::Promotion::Actions::CreateItemAdjustments Spree::Promotion::Actions::CreateLineItems]
|
|
57
68
|
when :fulfillment
|
|
58
69
|
%w[Spree::Promotion::Actions::FreeShipping Spree::Promotion::Actions::FreeShipping]
|
|
59
70
|
when :order
|
|
@@ -80,8 +80,7 @@ module Spree
|
|
|
80
80
|
clear_persisted_coupon_code(coupon_code)
|
|
81
81
|
|
|
82
82
|
if promotion.multi_codes?
|
|
83
|
-
|
|
84
|
-
coupon_code&.remove_from_order
|
|
83
|
+
promotion.coupon_codes.held_by(order).first&.remove_from_order
|
|
85
84
|
else
|
|
86
85
|
promotion.touch
|
|
87
86
|
end
|
|
@@ -217,7 +216,10 @@ module Spree
|
|
|
217
216
|
end
|
|
218
217
|
|
|
219
218
|
if discount || created_line_items
|
|
220
|
-
|
|
219
|
+
if discount && !claim_coupon_code(discount, coupon_code)
|
|
220
|
+
release_coupon_code(order, coupon_code)
|
|
221
|
+
return set_error_code :coupon_code_used
|
|
222
|
+
end
|
|
221
223
|
|
|
222
224
|
order.recalculate_totals!
|
|
223
225
|
set_success_code :coupon_code_applied
|
|
@@ -236,8 +238,51 @@ module Spree
|
|
|
236
238
|
end
|
|
237
239
|
end
|
|
238
240
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
# A single-use code is spent only when an order is placed with it, so a
|
|
242
|
+
# code sitting on another open cart is taken from it rather than
|
|
243
|
+
# refused: whoever presents it now gets the discount, and whoever places
|
|
244
|
+
# an order first keeps the code. A cart in the middle of checkout keeps
|
|
245
|
+
# it, and so does a draft order, since its totals are fixed and money
|
|
246
|
+
# may already be at the gateway.
|
|
247
|
+
#
|
|
248
|
+
# The holder is locked before the code, the same order checkout takes
|
|
249
|
+
# them in, so the two serialize instead of deadlocking.
|
|
250
|
+
#
|
|
251
|
+
# @return [Boolean] false when the code can no longer be claimed
|
|
252
|
+
def claim_coupon_code(discount, coupon_code)
|
|
253
|
+
record = Spree::CouponCode.find_by(promotion_id: discount.promotion_id, code: coupon_code)
|
|
254
|
+
return true if record.nil?
|
|
255
|
+
|
|
256
|
+
holder = record.holder
|
|
257
|
+
holder = nil if holder && same_record?(holder, order)
|
|
258
|
+
|
|
259
|
+
Spree::CouponCode.transaction do
|
|
260
|
+
holder&.lock!
|
|
261
|
+
record.lock!
|
|
262
|
+
next false if record.used? || !same_holder?(record.holder, holder) || !releasable?(holder)
|
|
263
|
+
|
|
264
|
+
release_coupon_code(holder, coupon_code) if holder
|
|
265
|
+
record.apply_order!(order)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Gift cards are turned off so a holder paying with one keeps it.
|
|
270
|
+
def release_coupon_code(holder, coupon_code)
|
|
271
|
+
self.class.new(holder, enable_gift_cards: false).remove(coupon_code)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def releasable?(holder)
|
|
275
|
+
return true if holder.nil?
|
|
276
|
+
|
|
277
|
+
holder.is_a?(Spree::Cart) && holder.completed_at.nil? && !holder.completion_claimed?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def same_record?(holder, other)
|
|
281
|
+
holder.class == other.class && holder.id == other.id
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def same_holder?(current, expected)
|
|
285
|
+
current.nil? || same_record?(current, expected || order)
|
|
241
286
|
end
|
|
242
287
|
|
|
243
288
|
# Whether the coupon handler should also handle gift card codes.
|
|
@@ -55,6 +55,20 @@ module Spree
|
|
|
55
55
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
|
+
# Resolves ids, prefixed or raw, through the promotion's store, so another
|
|
59
|
+
# store's record raises instead of being linked. A rule built before its
|
|
60
|
+
# promotion is known passes the ids through; its validation checks them.
|
|
61
|
+
#
|
|
62
|
+
# @param ids [Array<String, Integer>]
|
|
63
|
+
# @param scope [ActiveRecord::Relation, nil] the store's records
|
|
64
|
+
# @return [Array<Integer>]
|
|
65
|
+
def ids_within_store(ids, scope)
|
|
66
|
+
ids = Array(ids).reject(&:blank?)
|
|
67
|
+
return ids if scope.nil?
|
|
68
|
+
|
|
69
|
+
ids.map { |id| Spree::PrefixedId.prefixed_id?(id) ? scope.find_by_param!(id).id : scope.find(id).id }
|
|
70
|
+
end
|
|
71
|
+
|
|
58
72
|
def eligibility_error_message(key, options = {})
|
|
59
73
|
Spree.t(key, Hash[scope: [:eligibility_errors, :messages]].merge(options))
|
|
60
74
|
end
|
data/app/models/spree/refund.rb
CHANGED
|
@@ -2,6 +2,7 @@ module Spree
|
|
|
2
2
|
class Refund < Spree.base_class
|
|
3
3
|
has_prefix_id :re # Stripe: re_
|
|
4
4
|
|
|
5
|
+
include Spree::ActedBy
|
|
5
6
|
include Spree::HasCustomFields
|
|
6
7
|
include Spree::Metadata
|
|
7
8
|
include Spree::InstrumentsGatewayCalls
|
|
@@ -18,7 +19,7 @@ module Spree
|
|
|
18
19
|
# payment covers several and only one of them is being refunded.
|
|
19
20
|
belongs_to :order, class_name: 'Spree::Order', optional: true, inverse_of: :refunds
|
|
20
21
|
belongs_to :reason, class_name: 'Spree::RefundReason', foreign_key: :refund_reason_id
|
|
21
|
-
|
|
22
|
+
acted_by :refunder
|
|
22
23
|
# What triggered this refund — a Spree::Return today, later an Exchange
|
|
23
24
|
# or Claim; nil for a manual refund. Deliberately polymorphic: the set is
|
|
24
25
|
# small and closed, and refunds are never bulk-queried in a hot path.
|
data/app/models/spree/return.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Spree
|
|
|
14
14
|
has_prefix_id :ret
|
|
15
15
|
|
|
16
16
|
has_spree_number prefix: 'RET'
|
|
17
|
+
include Spree::ActedBy
|
|
17
18
|
include Spree::NumberIdentifier
|
|
18
19
|
include Spree::SingleStoreResource
|
|
19
20
|
include Spree::HasStatus
|
|
@@ -31,7 +32,7 @@ module Spree
|
|
|
31
32
|
belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true, inverse_of: :returns
|
|
32
33
|
# Staff only. Customer-initiated returns leave this nil — the requester
|
|
33
34
|
# is always order.customer, so no second association is needed.
|
|
34
|
-
|
|
35
|
+
acted_by :created_by
|
|
35
36
|
|
|
36
37
|
has_many :return_line_items, class_name: 'Spree::ReturnLineItem',
|
|
37
38
|
dependent: :destroy, inverse_of: :return
|
|
@@ -110,21 +111,31 @@ module Spree
|
|
|
110
111
|
end
|
|
111
112
|
end
|
|
112
113
|
|
|
114
|
+
# Whether the warehouse has reported on this return.
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def counted?
|
|
118
|
+
received? || refunded?
|
|
119
|
+
end
|
|
120
|
+
|
|
113
121
|
# What the customer is owed for the items being returned.
|
|
114
122
|
def refund_total
|
|
115
|
-
return_line_items.sum(&:
|
|
123
|
+
return_line_items.sum(&:refund_amount)
|
|
116
124
|
end
|
|
117
125
|
|
|
118
|
-
# What a refund from this return paid for, line by line.
|
|
119
|
-
# warehouse
|
|
120
|
-
#
|
|
126
|
+
# What a refund from this return paid for, line by line. Only ever what
|
|
127
|
+
# the warehouse counted: the seller's clawback is built from this, and a
|
|
128
|
+
# line nobody counted earned nothing back.
|
|
121
129
|
#
|
|
122
130
|
# @return [Hash{Integer => BigDecimal}] line item id => amount
|
|
123
131
|
def refunded_line_amounts
|
|
132
|
+
return {} unless counted?
|
|
133
|
+
|
|
124
134
|
return_line_items.each_with_object(Hash.new(0)) do |line, amounts|
|
|
125
|
-
|
|
135
|
+
amount = line.refund_amount
|
|
136
|
+
next if amount.zero?
|
|
126
137
|
|
|
127
|
-
amounts[line.line_item_id] +=
|
|
138
|
+
amounts[line.line_item_id] += amount
|
|
128
139
|
end
|
|
129
140
|
end
|
|
130
141
|
|
|
@@ -19,6 +19,25 @@ module Spree
|
|
|
19
19
|
|
|
20
20
|
delegate :order, :currency, to: :return
|
|
21
21
|
|
|
22
|
+
# What this line refunds. Once the warehouse has counted, only the units
|
|
23
|
+
# that arrived are paid for — a customer who announced three and sent two
|
|
24
|
+
# is owed two — and before the count there is nothing to go on but the
|
|
25
|
+
# announced quantity.
|
|
26
|
+
#
|
|
27
|
+
# Rounded to the currency, because this is the ceiling a refund is checked
|
|
28
|
+
# against as well as the figure the dialog offers: a third of $29.99 taken
|
|
29
|
+
# three times is not $29.99 until it is.
|
|
30
|
+
#
|
|
31
|
+
# @return [BigDecimal]
|
|
32
|
+
def refund_amount
|
|
33
|
+
return pre_tax_amount unless self.return.counted?
|
|
34
|
+
return 0.to_d if quantity.to_i.zero?
|
|
35
|
+
|
|
36
|
+
Spree::Money::Rounding.to_currency(
|
|
37
|
+
(pre_tax_amount / quantity) * received_quantity.to_i, currency
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
22
41
|
def display_pre_tax_amount
|
|
23
42
|
Spree::Money.new(pre_tax_amount, currency: currency)
|
|
24
43
|
end
|
|
@@ -7,6 +7,14 @@ module Spree
|
|
|
7
7
|
'best_selling' => :by_best_selling
|
|
8
8
|
}.freeze
|
|
9
9
|
|
|
10
|
+
# @param store [Spree::Store]
|
|
11
|
+
# @param audience [Symbol, nil] who filters: `:store` (shoppers) limits
|
|
12
|
+
# filters to what the storefront may query; `nil` is the back office
|
|
13
|
+
def initialize(store, audience: :store)
|
|
14
|
+
super(store)
|
|
15
|
+
@audience = audience
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
11
19
|
filters = filters.is_a?(Hash) ? filters.dup : {}
|
|
12
20
|
option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
|
|
@@ -72,7 +80,7 @@ module Spree
|
|
|
72
80
|
|
|
73
81
|
ransack_filters = sanitize_filters(filters)
|
|
74
82
|
if ransack_filters.present?
|
|
75
|
-
search = scope.ransack(ransack_filters)
|
|
83
|
+
search = scope.ransack(ransack_filters, auth_object: @audience)
|
|
76
84
|
scope = search.result(distinct: true)
|
|
77
85
|
end
|
|
78
86
|
|
|
@@ -127,7 +135,7 @@ module Spree
|
|
|
127
135
|
end
|
|
128
136
|
}.join(',')
|
|
129
137
|
|
|
130
|
-
scope.ransack(s: ransack_sort).result
|
|
138
|
+
scope.ransack({ s: ransack_sort }, auth_object: @audience).result
|
|
131
139
|
end
|
|
132
140
|
end
|
|
133
141
|
|
|
@@ -58,7 +58,9 @@ module Spree
|
|
|
58
58
|
scope :owed, -> { where(status: %w[pending processing]) }
|
|
59
59
|
|
|
60
60
|
self.whitelisted_ransackable_attributes = %w[amount currency status provider reference period_start period_end created_at seller_id]
|
|
61
|
-
|
|
61
|
+
# No `transfers`: they lead on to the order and its buyer, which a
|
|
62
|
+
# payouts-only caller may not read.
|
|
63
|
+
self.whitelisted_ransackable_associations = %w[seller]
|
|
62
64
|
|
|
63
65
|
extend Spree::DisplayMoney
|
|
64
66
|
money_methods :amount
|
|
@@ -112,7 +112,9 @@ module Spree
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
self.whitelisted_ransackable_attributes = %w[amount currency kind status provider reference created_at seller_id order_id payout_id]
|
|
115
|
-
|
|
115
|
+
# No `order`: filtering through it reaches the buyer's email and address,
|
|
116
|
+
# which a payouts-only caller may not read. `order_id` covers the lookup.
|
|
117
|
+
self.whitelisted_ransackable_associations = %w[seller payout refund]
|
|
116
118
|
|
|
117
119
|
extend Spree::DisplayMoney
|
|
118
120
|
money_methods :amount
|