spree_core 6.0.0.rc2 → 6.0.0.rc4
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/mailers/spree/email_template_mailer.rb +5 -2
- data/app/models/concerns/spree/has_external_references.rb +7 -6
- data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/spree/cart.rb +1 -0
- data/app/models/spree/delivery_rate.rb +12 -4
- data/app/models/spree/delivery_rate_provider/base.rb +1 -1
- data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
- data/app/models/spree/fulfillment.rb +1 -0
- data/app/models/spree/gateway/bogus.rb +7 -0
- data/app/models/spree/line_item.rb +3 -8
- data/app/models/spree/oauth_access_grant.rb +11 -0
- data/app/models/spree/oauth_access_token.rb +16 -0
- data/app/models/spree/oauth_application.rb +97 -0
- data/app/models/spree/oauth_consent_request.rb +23 -0
- data/app/models/spree/oauth_protected_resource.rb +21 -0
- data/app/models/spree/oauth_redirect.rb +12 -0
- data/app/models/spree/option_type.rb +5 -0
- data/app/models/spree/option_value.rb +5 -0
- data/app/models/spree/order.rb +5 -1
- data/app/models/spree/payment.rb +14 -2
- data/app/models/spree/payment_capture_event.rb +2 -0
- data/app/models/spree/policy.rb +6 -0
- data/app/models/spree/price.rb +16 -33
- data/app/models/spree/price_rule.rb +3 -16
- data/app/models/spree/price_rules/market_rule.rb +0 -6
- data/app/models/spree/pricing_provider/base.rb +1 -1
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/purchase_order_item.rb +1 -1
- data/app/models/spree/return.rb +1 -0
- data/app/models/spree/search_provider/database.rb +1 -1
- data/app/models/spree/stock/estimator.rb +4 -20
- data/app/models/spree/store.rb +8 -0
- data/app/models/spree/tax_exemption/item_override.rb +14 -0
- data/app/models/spree/tax_exemption.rb +26 -0
- data/app/models/spree/tax_exemption_certificate.rb +13 -2
- data/app/models/spree/tax_exemption_reason.rb +62 -0
- data/app/models/spree/tax_provider/base.rb +24 -4
- data/app/models/spree/tax_provider/internal.rb +36 -29
- data/app/models/spree/tax_rate.rb +9 -35
- data/app/models/spree/translations/batch.rb +14 -4
- data/app/services/spree/carts/price_items.rb +2 -8
- data/app/services/spree/carts/split_by_seller.rb +6 -1
- data/app/services/spree/carts/update.rb +34 -7
- data/app/services/spree/email_templates/preview.rb +24 -3
- data/app/services/spree/seeds/store_resources.rb +1 -0
- data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
- data/app/services/spree/tax/resolve_exemptions.rb +11 -2
- data/app/services/spree/update_check.rb +8 -1
- data/app/tools/spree/agent_tools/create_export.rb +116 -0
- data/app/tools/spree/agent_tools/create_import.rb +117 -0
- data/app/tools/spree/agent_tools/create_media.rb +81 -0
- data/app/tools/spree/agent_tools/create_resource.rb +36 -0
- data/app/tools/spree/agent_tools/curate_products.rb +90 -0
- data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
- data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
- data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
- data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
- data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
- data/app/tools/spree/agent_tools/get_resource.rb +97 -0
- data/app/tools/spree/agent_tools/import_tool.rb +80 -0
- data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
- data/app/tools/spree/agent_tools/query_report.rb +124 -0
- data/app/tools/spree/agent_tools/resource_write.rb +99 -0
- data/app/tools/spree/agent_tools/search_resources.rb +155 -0
- data/app/tools/spree/agent_tools/set_stock.rb +52 -0
- data/app/tools/spree/agent_tools/update_resource.rb +42 -0
- data/app/tools/spree/agent_tools/upload_file.rb +104 -0
- data/app/workflows/spree/carts/complete.rb +115 -2
- data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
- data/app/workflows/spree/orders/cancel.rb +8 -0
- data/app/workflows/spree/orders/complete.rb +8 -0
- data/app/{services → workflows}/spree/orders/create.rb +9 -9
- data/app/workflows/spree/purchase_orders/create.rb +3 -0
- data/app/workflows/spree/returns/refund.rb +4 -0
- data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
- data/app/workflows/spree/variants/create.rb +7 -4
- data/config/locales/en.yml +14 -0
- data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
- data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
- data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
- data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
- data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
- data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
- data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/spree/agent_tool.rb +188 -0
- data/lib/spree/agent_tools/api_dispatch.rb +204 -0
- data/lib/spree/agent_tools/context.rb +191 -0
- data/lib/spree/agent_tools/default_catalog.rb +180 -0
- data/lib/spree/agent_tools/membership_map.rb +61 -0
- data/lib/spree/agent_tools/record_summary.rb +219 -0
- data/lib/spree/agent_tools/registry.rb +131 -0
- data/lib/spree/agent_tools/resource_map.rb +352 -0
- data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
- data/lib/spree/agent_tools/workflow_schema.rb +279 -0
- data/lib/spree/agent_tools/workflow_tool.rb +654 -0
- data/lib/spree/agent_tools.rb +24 -0
- data/lib/spree/core/configuration.rb +1 -1
- data/lib/spree/core/controller_helpers/store.rb +3 -13
- data/lib/spree/core/dependencies.rb +9 -0
- data/lib/spree/core/engine.rb +35 -1
- data/lib/spree/core/model_extensions.rb +111 -0
- data/lib/spree/core/permissions/default_catalog.rb +6 -1
- data/lib/spree/core/pricing/price_resolution.rb +4 -4
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -2
- data/lib/spree/service_module.rb +11 -0
- data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
- data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
- data/lib/tasks/markets.rake +47 -0
- data/lib/tasks/tax_exemption_reasons.rake +48 -0
- metadata +67 -7
- data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
|
@@ -37,19 +37,9 @@ module Spree
|
|
|
37
37
|
|
|
38
38
|
# Return a Hash of things that influence the prices displayed in your shop.
|
|
39
39
|
#
|
|
40
|
-
# By default
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
# If your prices depend on something else, overwrite this method and add
|
|
45
|
-
# more key/value pairs to the Hash it returns.
|
|
46
|
-
#
|
|
47
|
-
# Be careful though to also patch the following parts of Spree accordingly:
|
|
48
|
-
#
|
|
49
|
-
# * `Spree::VatPriceCalculation#gross_amount`
|
|
50
|
-
# * `Spree::LineItem#update_price`
|
|
51
|
-
# * `Spree::Stock::Estimator#taxation_options_for`
|
|
52
|
-
#
|
|
40
|
+
# By default that is the country whose tax applies. If your prices depend
|
|
41
|
+
# on something else, overwrite this method and add more key/value pairs
|
|
42
|
+
# to the Hash it returns.
|
|
53
43
|
def current_price_options
|
|
54
44
|
{
|
|
55
45
|
country: current_tax_country
|
|
@@ -70,6 +70,11 @@ module Spree
|
|
|
70
70
|
order_fee_create_service: 'Spree::Orders::Fees::Create',
|
|
71
71
|
order_fee_update_service: 'Spree::Orders::Fees::Update',
|
|
72
72
|
order_fee_destroy_service: 'Spree::Orders::Fees::Destroy',
|
|
73
|
+
# A workflow since 6.0: its `perform` signature is what the agent
|
|
74
|
+
# tools derive their schema from, which a service's opaque `params`
|
|
75
|
+
# hash could not provide. The `_service` key still resolves it, so a
|
|
76
|
+
# host app that overrode or called it keeps working.
|
|
77
|
+
order_create_workflow: 'Spree::Orders::Create',
|
|
73
78
|
order_create_service: 'Spree::Orders::Create',
|
|
74
79
|
order_create_from_cart_service: 'Spree::Orders::CreateFromCart',
|
|
75
80
|
order_update_service: 'Spree::Orders::Update',
|
|
@@ -98,6 +103,10 @@ module Spree
|
|
|
98
103
|
|
|
99
104
|
# inventory operations
|
|
100
105
|
stock_level_correct_service: 'Spree::StockLevels::Correct',
|
|
106
|
+
# A workflow since 6.0: its signature is what the agent tool derives
|
|
107
|
+
# its schema from, which `Spree::ServiceModule` could not provide.
|
|
108
|
+
# The `_service` key still resolves it.
|
|
109
|
+
stock_level_bulk_upsert_workflow: 'Spree::StockLevels::BulkUpsert',
|
|
101
110
|
stock_level_bulk_upsert_service: 'Spree::StockLevels::BulkUpsert',
|
|
102
111
|
stock_level_recount_service: 'Spree::StockLevels::Recount',
|
|
103
112
|
stock_transfer_create_workflow: 'Spree::StockTransfers::Create',
|
data/lib/spree/core/engine.rb
CHANGED
|
@@ -43,6 +43,8 @@ module Spree
|
|
|
43
43
|
:actor_classes,
|
|
44
44
|
:custom_fields,
|
|
45
45
|
:reporting,
|
|
46
|
+
:admin_agent_tools,
|
|
47
|
+
:store_agent_tools,
|
|
46
48
|
:integrations,
|
|
47
49
|
:number_generators,
|
|
48
50
|
:subscribers,
|
|
@@ -158,6 +160,20 @@ module Spree
|
|
|
158
160
|
Spree::Reporting::DefaultVocabulary.install(app.config.spree.reporting)
|
|
159
161
|
end
|
|
160
162
|
|
|
163
|
+
# Seed the agent-tool registry before app initializers so applications
|
|
164
|
+
# and extensions can register their own tools and expose their own
|
|
165
|
+
# workflows in config/initializers (see docs/plans/6.0-mcp-server.md).
|
|
166
|
+
# One registry per surface, the way the authentication strategies are
|
|
167
|
+
# split: a storefront agent and a back-office agent share the tool
|
|
168
|
+
# machinery but never the catalog, and a staff tool reaching a customer
|
|
169
|
+
# would be offering the back office to the public.
|
|
170
|
+
initializer 'spree.register.agent_tools', before: :load_config_initializers do |app|
|
|
171
|
+
app.config.spree.admin_agent_tools = Spree::AgentTools::Registry.new(audience: :admin)
|
|
172
|
+
app.config.spree.store_agent_tools = Spree::AgentTools::Registry.new(audience: :store)
|
|
173
|
+
Spree::AgentTools::DefaultCatalog.install(app.config.spree.admin_agent_tools)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
|
|
161
177
|
# Country and subdivision names are translated by the countries gem, which
|
|
162
178
|
# only loads the locales it is told about. This runs after initialization
|
|
163
179
|
# because Spree.available_locales reads i18n configuration that is not
|
|
@@ -587,7 +603,14 @@ module Spree
|
|
|
587
603
|
:verification_value,
|
|
588
604
|
:client_id,
|
|
589
605
|
:client_secret,
|
|
590
|
-
:refresh_token
|
|
606
|
+
:refresh_token,
|
|
607
|
+
# The OAuth token exchange posts these. The code is single-use and
|
|
608
|
+
# spent the moment it is logged, but the verifier beside it is what
|
|
609
|
+
# PKCE relies on — logging both hands anyone who can read the log
|
|
610
|
+
# everything they need to race the exchange.
|
|
611
|
+
:code,
|
|
612
|
+
:code_verifier,
|
|
613
|
+
:code_challenge
|
|
591
614
|
]
|
|
592
615
|
end
|
|
593
616
|
|
|
@@ -613,6 +636,13 @@ module Spree
|
|
|
613
636
|
end
|
|
614
637
|
end
|
|
615
638
|
|
|
639
|
+
# The same contexts, for the concerns extensions add to core models.
|
|
640
|
+
initializer 'spree.model_extensions.schedule_apply', after: :load_config_initializers do |app|
|
|
641
|
+
app.config.after_initialize do
|
|
642
|
+
Spree.model_extensions.apply
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
616
646
|
# A hook registered against a key no workflow declares would never fire
|
|
617
647
|
# and never say so. Checking after eager load turns that typo into a
|
|
618
648
|
# boot failure. Skipped when eager loading is off (development,
|
|
@@ -630,6 +660,10 @@ module Spree
|
|
|
630
660
|
ActsAsTaggableOn::Tag.include(Spree::RansackableAttributes)
|
|
631
661
|
ActsAsTaggableOn::Tag.whitelisted_ransackable_attributes = %w[id name]
|
|
632
662
|
|
|
663
|
+
# Reloading makes new model classes, which have none of the
|
|
664
|
+
# extensions' concerns yet.
|
|
665
|
+
Spree.model_extensions.apply
|
|
666
|
+
|
|
633
667
|
# Reset and re-activate event subscribers on code reload
|
|
634
668
|
# activate! will register all subscribers from Spree.subscribers
|
|
635
669
|
# Note: resolve_subscriber in register_subscribers! handles stale class references
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Adds an extension's own methods, associations, scopes and validations to
|
|
3
|
+
# core models without a decorator.
|
|
4
|
+
#
|
|
5
|
+
# An extension writes an ordinary concern and registers it against the
|
|
6
|
+
# model by name. Spree includes it, so the model's own methods still win:
|
|
7
|
+
# an extension adds to a model, it never replaces what the model does.
|
|
8
|
+
# A concern that would shadow a method, attribute or association the model
|
|
9
|
+
# already has is refused at boot, naming both, where a decorator would
|
|
10
|
+
# overwrite it quietly.
|
|
11
|
+
#
|
|
12
|
+
# Registrations are kept as names and included again each time code is
|
|
13
|
+
# reloaded, so development reloading keeps them.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# Spree.model_extensions.add('Spree::Product', 'MyExtension::ProductWarranty')
|
|
17
|
+
# Spree.model_extensions.add(:customer, 'MyExtension::CustomerWarranties')
|
|
18
|
+
class ModelExtensions
|
|
19
|
+
class Conflict < StandardError; end
|
|
20
|
+
|
|
21
|
+
# The configured customer and admin user classes, which an extension cannot know by name.
|
|
22
|
+
USER_MODELS = { customer: -> { Spree.customer_class }, admin_user: -> { Spree.admin_user_class } }.freeze
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@extensions = Hash.new { |hash, key| hash[key] = [] }
|
|
26
|
+
@prepared = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param model [String, Symbol] the model's class name, e.g. +'Spree::Product'+, or +:customer+ / +:admin_user+
|
|
30
|
+
# @param extension [String, Module] the concern to include
|
|
31
|
+
# @raise [ArgumentError] for a symbol other than +:customer+ and +:admin_user+
|
|
32
|
+
# @raise [Conflict] when the concern would shadow something the model already has
|
|
33
|
+
# @return [void]
|
|
34
|
+
def add(model, extension)
|
|
35
|
+
key = key_for(model)
|
|
36
|
+
name = extension.is_a?(Module) ? extension.name : extension.to_s
|
|
37
|
+
@extensions[key] |= [name]
|
|
38
|
+
apply_to(key, name) if @prepared
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param model [String, Symbol]
|
|
42
|
+
# @return [Array<String>] the concerns registered for the model
|
|
43
|
+
def for(model)
|
|
44
|
+
@extensions.fetch(key_for(model), []).dup
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Includes every registered concern. Spree runs it each time code is
|
|
48
|
+
# loaded; a registration made after that is included straight away.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
def apply
|
|
52
|
+
@prepared = true
|
|
53
|
+
@extensions.each { |key, names| names.each { |name| apply_to(key, name) } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def key_for(model)
|
|
59
|
+
return model.to_s unless model.is_a?(Symbol)
|
|
60
|
+
return model if USER_MODELS.key?(model)
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "#{model} is not a model: pass its class name, or :customer or :admin_user"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve(key)
|
|
66
|
+
key.is_a?(Symbol) ? USER_MODELS.fetch(key).call : key.constantize
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply_to(key, name)
|
|
70
|
+
model = resolve(key)
|
|
71
|
+
extension = name.constantize
|
|
72
|
+
return if model.include?(extension)
|
|
73
|
+
|
|
74
|
+
refuse_shadowed_methods!(model, extension)
|
|
75
|
+
existing = model.instance_methods
|
|
76
|
+
reflections = model.reflections.dup
|
|
77
|
+
model.include(extension)
|
|
78
|
+
|
|
79
|
+
# Associations are declared in the concern's +included+ block, so they
|
|
80
|
+
# are only known once it has run: one it declared or redeclared there
|
|
81
|
+
# must not take a name the model already answers to.
|
|
82
|
+
declared = model.reflections.reject { |name, reflection| reflections[name].equal?(reflection) }.keys
|
|
83
|
+
shadowed = declared.find { |name| reflections.key?(name) || existing.include?(name.to_sym) || attribute?(model, name) }
|
|
84
|
+
raise_conflict(model, extension, shadowed) if shadowed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refuse_shadowed_methods!(model, extension)
|
|
88
|
+
methods = extension.instance_methods(false) + extension.private_instance_methods(false)
|
|
89
|
+
taken = methods.find { |method| model.method_defined?(method) || model.private_method_defined?(method) || attribute?(model, method) }
|
|
90
|
+
raise_conflict(model, extension, taken) if taken
|
|
91
|
+
|
|
92
|
+
return unless extension.const_defined?(:ClassMethods, false)
|
|
93
|
+
|
|
94
|
+
taken = extension::ClassMethods.instance_methods(false).find { |method| model.respond_to?(method, true) }
|
|
95
|
+
raise_conflict(model, extension, taken) if taken
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Attribute methods are generated on first use, so a column is checked by
|
|
99
|
+
# name. Without a database to read the columns from, as when assets are
|
|
100
|
+
# compiled, there are none to shadow.
|
|
101
|
+
def attribute?(model, method)
|
|
102
|
+
model.attribute_names.include?(method.to_s.delete_suffix('='))
|
|
103
|
+
rescue ActiveRecord::ActiveRecordError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def raise_conflict(model, extension, method)
|
|
108
|
+
raise Conflict, "#{extension.name} would replace #{model.name}##{method}, which the model already has"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -167,7 +167,7 @@ module Spree
|
|
|
167
167
|
Spree::StockLocation, Spree::DeliveryProfile,
|
|
168
168
|
Spree::Market, Spree::TaxCategory, Spree::TaxRate, Spree::AllowedOrigin,
|
|
169
169
|
Spree::RefundReason, Spree::ReturnReason, Spree::ClaimReason,
|
|
170
|
-
Spree::OrderCancellationReason, Spree::Channel,
|
|
170
|
+
Spree::OrderCancellationReason, Spree::TaxExemptionReason, Spree::Channel,
|
|
171
171
|
Spree::OrderRoutingRule, Spree::CustomFieldDefinition, Spree::Policy]
|
|
172
172
|
})
|
|
173
173
|
|
|
@@ -202,6 +202,11 @@ module Spree
|
|
|
202
202
|
catalog.register_scope(:integrations, group: :settings, resources: -> { [Spree::Integration] })
|
|
203
203
|
|
|
204
204
|
catalog.register_scope(:api_keys, group: :access, resources: -> { [Spree::ApiKey] })
|
|
205
|
+
# Connecting an AI agent hands a third party a standing credential to
|
|
206
|
+
# the store, so it is its own permission rather than part of settings
|
|
207
|
+
# — a merchandiser who may edit a delivery zone is not thereby someone
|
|
208
|
+
# who may connect Claude to the back office.
|
|
209
|
+
catalog.register_scope(:oauth_applications, group: :access, resources: -> { [Spree::OauthApplication] })
|
|
205
210
|
catalog.register_scope(:staff, group: :access, resources: -> {
|
|
206
211
|
[Spree.admin_user_class, Spree::Invitation, Spree::Role, Spree::RoleUser]
|
|
207
212
|
})
|
|
@@ -103,10 +103,10 @@ module Spree
|
|
|
103
103
|
def stamp(price)
|
|
104
104
|
return if price.blank?
|
|
105
105
|
|
|
106
|
-
# The context always knows the variant, and
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
106
|
+
# The context always knows the variant, and everything downstream
|
|
107
|
+
# reads it — a provider that builds a Price without one would otherwise
|
|
108
|
+
# fail far from the provider that omitted it, with an obscure
|
|
109
|
+
# NoMethodError.
|
|
110
110
|
price.variant ||= context.variant
|
|
111
111
|
price.readonly!
|
|
112
112
|
price.price_source = provider.class.key
|
data/lib/spree/core/version.rb
CHANGED
data/lib/spree/core.rb
CHANGED
|
@@ -15,6 +15,10 @@ require 'alba'
|
|
|
15
15
|
require 'acts-as-taggable-on'
|
|
16
16
|
require 'awesome_nested_set'
|
|
17
17
|
require 'cancan'
|
|
18
|
+
# The OAuth models include Doorkeeper's mixins, so the gem has to be loaded
|
|
19
|
+
# before they are autoloaded — core owns the tables, api owns the server
|
|
20
|
+
# configuration.
|
|
21
|
+
require 'doorkeeper'
|
|
18
22
|
require 'countries/global'
|
|
19
23
|
require 'friendly_id'
|
|
20
24
|
require 'json_schemer'
|
|
@@ -358,7 +362,6 @@ module Spree
|
|
|
358
362
|
# @return [Class]
|
|
359
363
|
singleton_class.delegate :password_validator, :password_validator=, to: :spree_config
|
|
360
364
|
|
|
361
|
-
|
|
362
365
|
# Commission rule kinds selectable on a commission rate.
|
|
363
366
|
#
|
|
364
367
|
# @return [Array<Class>]
|
|
@@ -512,6 +515,15 @@ module Spree
|
|
|
512
515
|
# @return [Spree::Reporting::Registry]
|
|
513
516
|
singleton_class.delegate :reporting, :reporting=, to: :spree_config
|
|
514
517
|
|
|
518
|
+
# The agent-tool registry — what an MCP client or the dashboard assistant
|
|
519
|
+
# may do on a merchant's behalf. A contract like +Spree.integrations+ and
|
|
520
|
+
# +Spree.reporting+: an extension registers a tool without depending on
|
|
521
|
+
# either adapter. See docs/plans/6.0-mcp-server.md.
|
|
522
|
+
#
|
|
523
|
+
# @return [Spree::AgentTools::Registry]
|
|
524
|
+
singleton_class.delegate :admin_agent_tools, :admin_agent_tools=, to: :spree_config
|
|
525
|
+
singleton_class.delegate :store_agent_tools, :store_agent_tools=, to: :spree_config
|
|
526
|
+
|
|
515
527
|
# The permission catalog — the grant vocabulary shared by staff roles and
|
|
516
528
|
# secret API key scopes. Roles themselves are data (Spree::Role#permissions);
|
|
517
529
|
# code only registers the vocabulary.
|
|
@@ -539,6 +551,16 @@ module Spree
|
|
|
539
551
|
@ransack ||= RansackConfiguration.new
|
|
540
552
|
end
|
|
541
553
|
|
|
554
|
+
# Concerns extensions add to core models, instead of decorating them.
|
|
555
|
+
#
|
|
556
|
+
# @example
|
|
557
|
+
# Spree.model_extensions.add('Spree::Product', 'MyExtension::ProductWarranty')
|
|
558
|
+
#
|
|
559
|
+
# @return [Spree::ModelExtensions]
|
|
560
|
+
def self.model_extensions
|
|
561
|
+
@model_extensions ||= ModelExtensions.new
|
|
562
|
+
end
|
|
563
|
+
|
|
542
564
|
class << self
|
|
543
565
|
# Dynamic methods for core dependencies
|
|
544
566
|
#
|
|
@@ -634,6 +656,8 @@ require 'spree/money'
|
|
|
634
656
|
require 'spree/service_module'
|
|
635
657
|
require 'spree/workflow'
|
|
636
658
|
require 'spree/reporting'
|
|
659
|
+
require 'spree/agent_tool'
|
|
660
|
+
require 'spree/agent_tools'
|
|
637
661
|
require 'spree/events'
|
|
638
662
|
require 'spree/store_scope_guard'
|
|
639
663
|
require 'spree/uploads'
|
|
@@ -655,8 +679,9 @@ require 'spree/core/preferences/invalid_preferences'
|
|
|
655
679
|
require 'spree/core/preferences/json_conversion'
|
|
656
680
|
|
|
657
681
|
require 'spree/core/permission_configuration'
|
|
682
|
+
require 'spree/core/model_extensions'
|
|
658
683
|
require 'spree/core/ransack_configuration'
|
|
659
684
|
require 'spree/core/pricing/context'
|
|
660
685
|
require 'spree/core/pricing/price_resolution'
|
|
661
|
-
require 'spree/core/pricing/resolver'
|
|
662
686
|
require 'spree/core/tax/provider_error'
|
|
687
|
+
require 'spree/core/pricing/resolver'
|
data/lib/spree/service_module.rb
CHANGED
|
@@ -26,10 +26,21 @@ module Spree
|
|
|
26
26
|
ResultError = Struct.new(:value) do
|
|
27
27
|
def to_s
|
|
28
28
|
return value.full_messages.join(', ') if value&.respond_to?(:full_messages)
|
|
29
|
+
# A failure carrying a code beside its message (`failure(cart, code:,
|
|
30
|
+
# message:)`) reads as the message, not as the hash it travels in.
|
|
31
|
+
return value[:message].to_s if value.is_a?(Hash) && value.key?(:message)
|
|
29
32
|
|
|
30
33
|
value.to_s
|
|
31
34
|
end
|
|
32
35
|
|
|
36
|
+
# The machine-readable reason a failure named, when it named one, so a
|
|
37
|
+
# caller can answer an outage differently from a refusal.
|
|
38
|
+
#
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
def code
|
|
41
|
+
value[:code]&.to_s if value.is_a?(Hash)
|
|
42
|
+
end
|
|
43
|
+
|
|
33
44
|
def to_h
|
|
34
45
|
return value.messages if value&.respond_to?(:messages)
|
|
35
46
|
|
|
@@ -2,9 +2,21 @@ FactoryBot.define do
|
|
|
2
2
|
factory :tax_exemption_certificate, class: Spree::TaxExemptionCertificate do
|
|
3
3
|
company
|
|
4
4
|
sequence(:certificate_number) { |n| "CERT-#{n}" }
|
|
5
|
-
reason_code { 'resale' }
|
|
6
5
|
status { 'pending' }
|
|
7
6
|
|
|
7
|
+
transient do
|
|
8
|
+
reason_code { 'RESALE' }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# A certificate may only record a reason its store offers, so the reason
|
|
12
|
+
# is found or created in the company's store for whichever code a spec
|
|
13
|
+
# picks.
|
|
14
|
+
tax_exemption_reason do
|
|
15
|
+
company.store.tax_exemption_reasons.find_or_create_by!(code: reason_code.to_s.upcase) do |reason|
|
|
16
|
+
reason.name = reason_code.to_s.humanize
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
8
20
|
trait :verified do
|
|
9
21
|
status { 'verified' }
|
|
10
22
|
verified_at { Time.current }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
factory :tax_exemption_reason, class: Spree::TaxExemptionReason do
|
|
3
|
+
store { Spree::Store.find_by(default: true) || association(:store) }
|
|
4
|
+
sequence(:name) { |n| "Tax Exemption Reason #{n}" }
|
|
5
|
+
sequence(:code) { |n| "REASON_#{n}" }
|
|
6
|
+
active { true }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -470,6 +470,28 @@ steps:
|
|
|
470
470
|
once several stores hold tax configuration. The NOT NULL constraints land
|
|
471
471
|
in 6.1. Idempotent.
|
|
472
472
|
|
|
473
|
+
- id: backfill_market_tax_inclusive
|
|
474
|
+
name: "Decide whether each market's prices include tax"
|
|
475
|
+
task: "spree:markets:backfill_tax_inclusive"
|
|
476
|
+
notes: |
|
|
477
|
+
In Spree 6 the market decides whether its prices include tax; tax rates
|
|
478
|
+
no longer carry an "included in price" flag. Each market takes the
|
|
479
|
+
convention its rates had: all included sets it to include tax, all added
|
|
480
|
+
on top sets it to add tax at checkout, and a market with no rates keeps
|
|
481
|
+
its setting. A market whose rates mixed the two is left unchanged and
|
|
482
|
+
named in the output, to be set by hand.
|
|
483
|
+
|
|
484
|
+
Prices are no longer adjusted for buyers abroad. A store that had a
|
|
485
|
+
default tax zone in 5.x and prices including tax now charges buyers
|
|
486
|
+
outside that zone the price as entered, with their own tax inside it.
|
|
487
|
+
Prices with tax added on top were never adjusted, and still have tax
|
|
488
|
+
added at checkout.
|
|
489
|
+
|
|
490
|
+
Run after migrate_tax_zones and backfill_tax_store_ids, which give each
|
|
491
|
+
rate its country and store; it stops if a rate still has no store.
|
|
492
|
+
Idempotent: each rate's old flag is read once and then cleared, so a
|
|
493
|
+
re-run leaves every market as it is.
|
|
494
|
+
|
|
473
495
|
- id: backfill_order_coupon_codes
|
|
474
496
|
name: "Backfill coupon codes on placed orders"
|
|
475
497
|
task: "spree:backfill_order_coupon_codes"
|
|
@@ -683,6 +705,22 @@ steps:
|
|
|
683
705
|
Idempotent — a store that already has a default package type is left
|
|
684
706
|
alone, so a box edited since the upgrade is never overwritten.
|
|
685
707
|
|
|
708
|
+
- id: tax_exemption_reasons
|
|
709
|
+
name: "Give every store the default tax exemption reasons and link certificates to them"
|
|
710
|
+
task: "spree:upgrade:tax_exemption_reasons"
|
|
711
|
+
notes: |
|
|
712
|
+
Company tax exemption certificates point at one of the store's
|
|
713
|
+
exemption reasons, so a store without any cannot record a certificate.
|
|
714
|
+
Existing reason codes are saved in capitals, the way tax engines compare
|
|
715
|
+
them. Certificates recorded before reasons existed are linked to the
|
|
716
|
+
reason matching their old reason code; a code no reason holds gets a
|
|
717
|
+
reason of its own. Merchants using an external tax engine change each
|
|
718
|
+
reason's code to that engine's under Settings → Taxes → Exemption
|
|
719
|
+
reasons.
|
|
720
|
+
|
|
721
|
+
Idempotent — a reason the merchant already has under the same code or
|
|
722
|
+
name is left alone, and a certificate already linked is skipped.
|
|
723
|
+
|
|
686
724
|
- id: migrate_external_receives_to_purchase_orders
|
|
687
725
|
name: "Turn 5.x supplier receives into purchase orders, and give every transfer a store"
|
|
688
726
|
task: "spree:upgrade:migrate_external_receives_to_purchase_orders"
|
data/lib/tasks/markets.rake
CHANGED
|
@@ -42,5 +42,52 @@ namespace :spree do
|
|
|
42
42
|
puts " Created market '#{market.name}' with #{countries.size} countries for store '#{store.name}' (#{store.code})"
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
|
+
|
|
46
|
+
desc "Set each market's tax_inclusive from whether its tax rates were included in the price"
|
|
47
|
+
task backfill_tax_inclusive: :environment do
|
|
48
|
+
# A rate with no store is one spree:backfill_tax_store_ids has not bound
|
|
49
|
+
# yet, and a market decided without it would be decided on part of its rates.
|
|
50
|
+
if Spree::TaxRate.with_deleted.where(store_id: nil).where.not(included_in_price: nil).exists?
|
|
51
|
+
abort ' Some tax rates have no store yet. Run spree:backfill_tax_store_ids first, then run this again.'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
updated = 0
|
|
55
|
+
mixed = []
|
|
56
|
+
|
|
57
|
+
Spree::Store.find_each do |store|
|
|
58
|
+
# Each rate's old flag is read once and then cleared, so a re-run finds
|
|
59
|
+
# nothing to read and cannot undo a market the merchant changed since.
|
|
60
|
+
legacy_rates = Spree::TaxRate.with_deleted.where(store_id: store.id).where.not(included_in_price: nil)
|
|
61
|
+
next unless legacy_rates.exists?
|
|
62
|
+
|
|
63
|
+
ActiveRecord::Base.transaction do
|
|
64
|
+
store.markets.includes(:market_countries).find_each do |market|
|
|
65
|
+
# A rate with no country taxes every market's countries.
|
|
66
|
+
conventions = legacy_rates.where(deleted_at: nil, country_code: market.country_codes + [nil]).
|
|
67
|
+
distinct.pluck(:included_in_price)
|
|
68
|
+
next if conventions.empty?
|
|
69
|
+
|
|
70
|
+
if conventions.size > 1
|
|
71
|
+
mixed << market
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
next if market.tax_inclusive == conventions.first
|
|
75
|
+
|
|
76
|
+
market.update_columns(tax_inclusive: conventions.first, updated_at: Time.current)
|
|
77
|
+
updated += 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
legacy_rates.update_all(included_in_price: nil)
|
|
81
|
+
store.touch
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts " Set tax_inclusive on #{updated} market(s) from their tax rates."
|
|
86
|
+
|
|
87
|
+
mixed.each do |market|
|
|
88
|
+
puts " ! Left '#{market.name}' (store #{market.store.code}) unchanged: some of its rates were included in " \
|
|
89
|
+
"the price and some added on top. Set whether its prices include tax in the dashboard."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
45
92
|
end
|
|
46
93
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
namespace :spree do
|
|
2
|
+
namespace :upgrade do
|
|
3
|
+
desc <<~DESC
|
|
4
|
+
Gives every store the default tax exemption reasons, saves existing
|
|
5
|
+
reason codes in capitals, and links each existing certificate to the
|
|
6
|
+
reason matching its old reason code (Spree 6.0).
|
|
7
|
+
|
|
8
|
+
A code no reason holds gets a reason of its own, named after the code.
|
|
9
|
+
Idempotent: reasons the merchant already has are left alone, and a
|
|
10
|
+
certificate already linked is skipped.
|
|
11
|
+
DESC
|
|
12
|
+
task tax_exemption_reasons: :environment do
|
|
13
|
+
# Codes are stored in capitals since tax engines compare them that way;
|
|
14
|
+
# a lookup by code reads capitals too, so a lower-case row would never
|
|
15
|
+
# be found again. Runs before seeding so the seed finds these by code.
|
|
16
|
+
Spree::TaxExemptionReason.find_each do |reason|
|
|
17
|
+
code = Spree::TaxExemptionReason.normalize_value_for(:code, reason.code)
|
|
18
|
+
next if code == reason.code
|
|
19
|
+
|
|
20
|
+
if Spree::TaxExemptionReason.where(store_id: reason.store_id).where.not(id: reason.id).exists?(code: code)
|
|
21
|
+
puts " Left tax exemption reason #{reason.prefixed_id} as #{reason.code}: #{code} is taken in its store"
|
|
22
|
+
else
|
|
23
|
+
reason.update_columns(code: code, updated_at: Time.current)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Spree::Store.find_each do |store|
|
|
28
|
+
Spree::Seeds::TaxExemptionReasons.call(store: store)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
linked = 0
|
|
32
|
+
|
|
33
|
+
Spree::TaxExemptionCertificate.where(tax_exemption_reason_id: nil).where.not(reason_code: [nil, '']).
|
|
34
|
+
includes(company: :store).find_each do |certificate|
|
|
35
|
+
reasons = certificate.company.store.tax_exemption_reasons
|
|
36
|
+
code = Spree::TaxExemptionReason.normalize_value_for(:code, certificate.reason_code)
|
|
37
|
+
name = code.humanize
|
|
38
|
+
name = "#{name} (#{code})" if reasons.where.not(code: code).exists?(name: name)
|
|
39
|
+
|
|
40
|
+
reason = reasons.find_or_create_by!(code: code) { |new_reason| new_reason.name = name }
|
|
41
|
+
certificate.update_columns(tax_exemption_reason_id: reason.id, updated_at: Time.current)
|
|
42
|
+
linked += 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
puts " Linked #{linked} tax exemption certificate(s) to their reason" if linked.positive?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|