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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  6. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  7. data/app/models/spree/cart.rb +1 -0
  8. data/app/models/spree/delivery_rate.rb +12 -4
  9. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  10. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  11. data/app/models/spree/fulfillment.rb +1 -0
  12. data/app/models/spree/gateway/bogus.rb +7 -0
  13. data/app/models/spree/line_item.rb +3 -8
  14. data/app/models/spree/oauth_access_grant.rb +11 -0
  15. data/app/models/spree/oauth_access_token.rb +16 -0
  16. data/app/models/spree/oauth_application.rb +97 -0
  17. data/app/models/spree/oauth_consent_request.rb +23 -0
  18. data/app/models/spree/oauth_protected_resource.rb +21 -0
  19. data/app/models/spree/oauth_redirect.rb +12 -0
  20. data/app/models/spree/option_type.rb +5 -0
  21. data/app/models/spree/option_value.rb +5 -0
  22. data/app/models/spree/order.rb +5 -1
  23. data/app/models/spree/payment.rb +14 -2
  24. data/app/models/spree/payment_capture_event.rb +2 -0
  25. data/app/models/spree/policy.rb +6 -0
  26. data/app/models/spree/price.rb +16 -33
  27. data/app/models/spree/price_rule.rb +3 -16
  28. data/app/models/spree/price_rules/market_rule.rb +0 -6
  29. data/app/models/spree/pricing_provider/base.rb +1 -1
  30. data/app/models/spree/product.rb +1 -1
  31. data/app/models/spree/purchase_order_item.rb +1 -1
  32. data/app/models/spree/return.rb +1 -0
  33. data/app/models/spree/search_provider/database.rb +1 -1
  34. data/app/models/spree/stock/estimator.rb +4 -20
  35. data/app/models/spree/store.rb +8 -0
  36. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  37. data/app/models/spree/tax_exemption.rb +26 -0
  38. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  39. data/app/models/spree/tax_exemption_reason.rb +62 -0
  40. data/app/models/spree/tax_provider/base.rb +24 -4
  41. data/app/models/spree/tax_provider/internal.rb +36 -29
  42. data/app/models/spree/tax_rate.rb +9 -35
  43. data/app/models/spree/translations/batch.rb +14 -4
  44. data/app/services/spree/carts/price_items.rb +2 -8
  45. data/app/services/spree/carts/split_by_seller.rb +6 -1
  46. data/app/services/spree/carts/update.rb +34 -7
  47. data/app/services/spree/email_templates/preview.rb +24 -3
  48. data/app/services/spree/seeds/store_resources.rb +1 -0
  49. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  50. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  51. data/app/services/spree/update_check.rb +8 -1
  52. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  53. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  54. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  55. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  56. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  57. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  58. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  59. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  60. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  61. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  62. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  63. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  64. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  65. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  66. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  67. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  68. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  69. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  70. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  71. data/app/workflows/spree/carts/complete.rb +115 -2
  72. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  73. data/app/workflows/spree/orders/cancel.rb +8 -0
  74. data/app/workflows/spree/orders/complete.rb +8 -0
  75. data/app/{services → workflows}/spree/orders/create.rb +9 -9
  76. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  77. data/app/workflows/spree/returns/refund.rb +4 -0
  78. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  79. data/app/workflows/spree/variants/create.rb +7 -4
  80. data/config/locales/en.yml +14 -0
  81. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  82. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  83. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  84. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  85. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  86. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  87. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  88. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  89. data/db/sample_data/orders.rb +1 -1
  90. data/lib/spree/agent_tool.rb +188 -0
  91. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  92. data/lib/spree/agent_tools/context.rb +191 -0
  93. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  94. data/lib/spree/agent_tools/membership_map.rb +61 -0
  95. data/lib/spree/agent_tools/record_summary.rb +219 -0
  96. data/lib/spree/agent_tools/registry.rb +131 -0
  97. data/lib/spree/agent_tools/resource_map.rb +352 -0
  98. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  99. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  100. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  101. data/lib/spree/agent_tools.rb +24 -0
  102. data/lib/spree/core/configuration.rb +1 -1
  103. data/lib/spree/core/controller_helpers/store.rb +3 -13
  104. data/lib/spree/core/dependencies.rb +9 -0
  105. data/lib/spree/core/engine.rb +35 -1
  106. data/lib/spree/core/model_extensions.rb +111 -0
  107. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  108. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  109. data/lib/spree/core/version.rb +1 -1
  110. data/lib/spree/core.rb +27 -2
  111. data/lib/spree/service_module.rb +11 -0
  112. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  113. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  114. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  115. data/lib/tasks/markets.rake +47 -0
  116. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  117. metadata +67 -7
  118. 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, the only thing that influences prices is the country whose tax applies (to
41
- # facilitate differing prices depending on VAT rate for digital products in Europe, see
42
- # https://github.com/spree/spree/pull/6295 and https://github.com/spree/spree/pull/6662).
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',
@@ -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 the tax-inclusive
107
- # restatement reads it — a provider that builds a Price without one
108
- # would otherwise fail deep in that call with an obscure NoMethodError
109
- # rather than anywhere near the provider that omitted it.
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
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0.rc2'.freeze
2
+ VERSION = '6.0.0.rc4'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
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'
@@ -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"
@@ -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