spree_core 6.0.0.rc3 → 6.0.0.rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/app/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/addresses.rb +95 -32
  6. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  7. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  8. data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
  9. data/app/models/spree/ability.rb +8 -0
  10. data/app/models/spree/address.rb +6 -0
  11. data/app/models/spree/cart.rb +4 -6
  12. data/app/models/spree/company.rb +1 -1
  13. data/app/models/spree/data_request.rb +34 -1
  14. data/app/models/spree/delivery_rate.rb +12 -4
  15. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  16. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  17. data/app/models/spree/fulfillment.rb +1 -0
  18. data/app/models/spree/gateway/bogus.rb +7 -0
  19. data/app/models/spree/line_item.rb +3 -8
  20. data/app/models/spree/oauth_access_grant.rb +11 -0
  21. data/app/models/spree/oauth_access_token.rb +16 -0
  22. data/app/models/spree/oauth_application.rb +97 -0
  23. data/app/models/spree/oauth_consent_request.rb +23 -0
  24. data/app/models/spree/oauth_protected_resource.rb +21 -0
  25. data/app/models/spree/oauth_redirect.rb +12 -0
  26. data/app/models/spree/option_type.rb +5 -0
  27. data/app/models/spree/option_value.rb +5 -0
  28. data/app/models/spree/order.rb +6 -2
  29. data/app/models/spree/payment.rb +14 -2
  30. data/app/models/spree/payment_capture_event.rb +2 -0
  31. data/app/models/spree/policy.rb +6 -0
  32. data/app/models/spree/price.rb +16 -33
  33. data/app/models/spree/price_rule.rb +3 -16
  34. data/app/models/spree/price_rules/market_rule.rb +0 -6
  35. data/app/models/spree/pricing_provider/base.rb +1 -1
  36. data/app/models/spree/product.rb +1 -1
  37. data/app/models/spree/purchase_order_item.rb +1 -1
  38. data/app/models/spree/return.rb +1 -0
  39. data/app/models/spree/sales_context.rb +162 -0
  40. data/app/models/spree/search_provider/database.rb +1 -1
  41. data/app/models/spree/stock/estimator.rb +4 -20
  42. data/app/models/spree/store.rb +8 -0
  43. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  44. data/app/models/spree/tax_exemption.rb +26 -0
  45. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  46. data/app/models/spree/tax_exemption_reason.rb +62 -0
  47. data/app/models/spree/tax_line.rb +5 -4
  48. data/app/models/spree/tax_provider/base.rb +24 -4
  49. data/app/models/spree/tax_provider/internal.rb +36 -29
  50. data/app/models/spree/tax_rate.rb +9 -35
  51. data/app/models/spree/translations/batch.rb +14 -4
  52. data/app/services/spree/carts/destroy.rb +7 -2
  53. data/app/services/spree/carts/price_items.rb +2 -8
  54. data/app/services/spree/carts/split_by_seller.rb +6 -1
  55. data/app/services/spree/carts/update.rb +35 -11
  56. data/app/services/spree/data_requests/confirm.rb +37 -0
  57. data/app/services/spree/data_requests/create.rb +61 -13
  58. data/app/services/spree/email_templates/preview.rb +24 -3
  59. data/app/services/spree/seeds/store_resources.rb +1 -0
  60. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  61. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  62. data/app/services/spree/update_check.rb +8 -1
  63. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  64. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  65. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  66. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  67. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  68. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  69. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  70. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  71. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  72. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  73. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  74. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  75. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  76. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  77. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  78. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  79. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  80. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  81. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  82. data/app/workflows/spree/carts/complete.rb +115 -2
  83. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  84. data/app/workflows/spree/customers/anonymize.rb +38 -17
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/orders/cancel.rb +8 -0
  87. data/app/workflows/spree/orders/complete.rb +8 -0
  88. data/app/{services → workflows}/spree/orders/create.rb +50 -26
  89. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  90. data/app/workflows/spree/returns/refund.rb +4 -0
  91. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  92. data/app/workflows/spree/variants/create.rb +7 -4
  93. data/config/locales/en.yml +18 -0
  94. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  95. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  96. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  97. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  98. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  99. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  100. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  101. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  102. data/db/sample_data/orders.rb +1 -1
  103. data/lib/spree/agent_tool.rb +188 -0
  104. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  105. data/lib/spree/agent_tools/context.rb +191 -0
  106. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  107. data/lib/spree/agent_tools/membership_map.rb +61 -0
  108. data/lib/spree/agent_tools/record_summary.rb +219 -0
  109. data/lib/spree/agent_tools/registry.rb +131 -0
  110. data/lib/spree/agent_tools/resource_map.rb +352 -0
  111. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  112. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  113. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  114. data/lib/spree/agent_tools.rb +24 -0
  115. data/lib/spree/core/configuration.rb +1 -1
  116. data/lib/spree/core/controller_helpers/store.rb +3 -13
  117. data/lib/spree/core/dependencies.rb +9 -0
  118. data/lib/spree/core/engine.rb +37 -1
  119. data/lib/spree/core/model_extensions.rb +111 -0
  120. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  121. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  122. data/lib/spree/core/version.rb +1 -1
  123. data/lib/spree/core.rb +27 -2
  124. data/lib/spree/service_module.rb +11 -0
  125. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  126. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  127. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  128. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  129. data/lib/tasks/markets.rake +47 -0
  130. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  131. metadata +69 -7
  132. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -12,18 +12,22 @@ module Spree
12
12
  # A feed states absolute levels (+count_on_hand+), which is what an
13
13
  # external system knows. The movement records the delta between what Spree
14
14
  # held and what the feed says.
15
- class BulkUpsert
16
- prepend Spree::ServiceModule::Base
15
+ class BulkUpsert < Spree::Workflow
17
16
 
18
17
  # What the stock history shows against a movement this service wrote.
19
18
  FEED_REASON = 'inventory_feed'.freeze
20
19
 
21
- # @param rows [Array<Hash>] each with +variant_id+, +stock_location_id+,
22
- # and either +count_on_hand+ (absolute) or +adjustment+ (relative);
23
- # +backorderable+ optional
20
+ # @param store [Spree::Store] whose shelves these are. Ids are resolved
21
+ # through it, so a row naming another store's variant is not found
22
+ # rather than written.
23
+ # @param rows [Array<Hash>] one per shelf: +variant_id+ and
24
+ # +stock_location_id+ naming it (prefixed ids, or raw), then either
25
+ # +count_on_hand+ (the figure it should end at) or +adjustment+ (a
26
+ # signed change); +backorderable+ optional
24
27
  # @return [Spree::ServiceModule::Result] +{ stock_level_count: N }+
25
- def call(rows:)
26
- rows = Array(rows).map { |row| row.with_indifferent_access }
28
+ def perform(store:, rows:)
29
+ @store = store
30
+ rows = resolve(Array(rows).map { |row| row.with_indifferent_access })
27
31
  keyed = rows.select { |row| row[:variant_id].present? && row[:stock_location_id].present? }
28
32
  return success(stock_level_count: 0) if keyed.empty?
29
33
 
@@ -41,6 +45,44 @@ module Spree
41
45
 
42
46
  private
43
47
 
48
+ # Names become this store's own ids, or nothing.
49
+ #
50
+ # Resolved here rather than by the caller, because a row reaching
51
+ # `find_or_initialize_by` unscoped writes to whichever shelf the id
52
+ # happens to name — including another merchant's. One caller decoding
53
+ # ids correctly does not make the workflow safe for the next one.
54
+ def resolve(rows)
55
+ variants = scoped_ids(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
56
+ locations = scoped_ids(store_stock_locations, Spree::StockLocation,
57
+ rows.map { |row| row[:stock_location_id] })
58
+
59
+ rows.map do |row|
60
+ row.merge(
61
+ variant_id: variants[row[:variant_id]],
62
+ stock_location_id: locations[row[:stock_location_id]]
63
+ ).compact
64
+ end
65
+ end
66
+
67
+ # Accepts a prefixed id or a raw one: the Admin API speaks prefixed,
68
+ # while the importer and seeds hold records they already loaded.
69
+ def scoped_ids(scope, model, values)
70
+ decoded = values.compact.uniq.index_with do |value|
71
+ model.decode_own_prefixed_id(value) || value
72
+ end
73
+ found = scope.where(id: decoded.values.compact).pluck(:id).map(&:to_s).to_set
74
+
75
+ decoded.transform_values { |id| id if id && found.include?(id.to_s) }
76
+ end
77
+
78
+ def store_variants
79
+ @store.variants
80
+ end
81
+
82
+ def store_stock_locations
83
+ @store.stock_locations
84
+ end
85
+
44
86
  def apply_row(row)
45
87
  stock_level = Spree::StockLevel.find_or_initialize_by(
46
88
  variant_id: row[:variant_id], stock_location_id: row[:stock_location_id]
@@ -1,9 +1,12 @@
1
1
  module Spree
2
2
  module Variants
3
- # Creates a variant on a product. The single gate for variant creation —
4
- # the standalone variants endpoint and the nested payload on a product
5
- # write both run through it, so a :validate handler sees every variant a
6
- # catalog gains.
3
+ # Adds one variant to a product that already exists. A product being
4
+ # created carries its variants in the same write instead, which is one
5
+ # request and reconciles the list rather than adding to it.
6
+ #
7
+ # The single gate for variant creation — the standalone variants endpoint
8
+ # and the nested payload on a product write both run through it, so a
9
+ # :validate handler sees every variant a catalog gains.
7
10
  #
8
11
  # The product is a parameter rather than something read off the payload
9
12
  # because everything nested needs it: an option value attaches an option
@@ -429,6 +429,12 @@ en:
429
429
  attributes:
430
430
  company:
431
431
  must_be_legal_entity: must be a legal entity, not a division
432
+ tax_exemption_reason:
433
+ not_offered: is not a tax exemption reason this store offers
434
+ spree/tax_exemption_reason:
435
+ attributes:
436
+ base:
437
+ in_use: is recorded on tax exemption certificates and cannot be deleted
432
438
  spree/tax_identifier:
433
439
  attributes:
434
440
  company:
@@ -613,8 +619,10 @@ en:
613
619
  messages:
614
620
  already_confirmed: was already confirmed
615
621
  forbidden_reporting_member: references data you are not allowed to read
622
+ fragment_present: cannot contain a fragment
616
623
  invalid_document_number_affix: 'can only contain uppercase letters, digits, dashes and #'
617
624
  invalid_system_key: can only contain lowercase letters, digits and underscores
625
+ invalid_uri: must be a valid URL
618
626
  locked_after_first_number: cannot change once numbering has started
619
627
  not_a_number: is not a number
620
628
  not_found: not found
@@ -622,7 +630,10 @@ en:
622
630
  not_saved:
623
631
  one: '1 error prohibited this %{resource} from being saved:'
624
632
  other: "%{count} errors prohibited this %{resource} from being saved:"
633
+ relative_uri: must be an absolute URL
634
+ secured_uri: must start with https, because authorization codes are delivered to it
625
635
  seeded_report_read_only: is a built-in report and cannot be changed — save a copy instead
636
+ unspecified_scheme: must include a scheme, such as https
626
637
  number:
627
638
  currency:
628
639
  format:
@@ -688,6 +699,7 @@ en:
688
699
  pricing_unavailable: Prices could not be confirmed. Please try again.
689
700
  quantity_must_be_positive: Quantity must be at least 1. Use DELETE to remove the item.
690
701
  quantity_rule_violated: "%{li_name} cannot be ordered in that quantity. Try %{quantities}."
702
+ tax_unavailable: Tax could not be calculated. Please try again.
691
703
  catalogs:
692
704
  no_price_list_to_import: This catalog has no price list to import products from.
693
705
  product_not_in_store: One of those products belongs to another store.
@@ -753,6 +765,7 @@ en:
753
765
  created_at: Created at
754
766
  credit_card: Credit Card
755
767
  currency: Currency
768
+ currency_does_not_match_market: must be %{currency}, the currency of the chosen market
756
769
  currency_not_supported_by_store: is not supported by this store
757
770
  custom_field_definitions: Custom Field Definitions
758
771
  custom_fields: Custom Fields
@@ -761,7 +774,9 @@ en:
761
774
  anonymize_failed: The erasure could not be completed and nothing was changed
762
775
  customers: Customers
763
776
  data_request_errors:
777
+ address_changed: The account's email address changed after this request was made
764
778
  already_anonymized: This customer's personal data has already been erased
779
+ confirmation_invalid: This confirmation link has expired or is no longer valid
765
780
  download_unavailable: This download link has expired or is no longer valid
766
781
  not_pending: This request has already been processed
767
782
  default: Default
@@ -1004,6 +1019,7 @@ en:
1004
1019
  line_items: Line items
1005
1020
  link: Link
1006
1021
  locale: Locale
1022
+ locale_not_supported_by_market: is not offered by the chosen market
1007
1023
  locale_not_supported_by_store: is not supported by this store
1008
1024
  locales_not_listed: 'includes locales Spree does not support: %{codes}'
1009
1025
  manual: Manual
@@ -1785,6 +1801,8 @@ en:
1785
1801
  gb_vat: UK VAT
1786
1802
  us_ein: US EIN
1787
1803
  tax_provider_unavailable: The tax service could not work out the tax to give back. Try again in a few minutes.
1804
+ tax_providers:
1805
+ internal: Spree tax rates
1788
1806
  terms_of_service: Terms of Service
1789
1807
  there_are_no_items_for_this_order: There are no items for this order
1790
1808
  tiered_flat_rate: Tiered Flat Rate
@@ -2,10 +2,12 @@ class FixPoliciesStoreAssociation < ActiveRecord::Migration[7.2]
2
2
  def change
3
3
  add_reference :spree_policies, :owner, polymorphic: true, index: true
4
4
 
5
- Spree::Policy.reset_column_information
6
- Spree::Policy.all.each do |policy|
7
- policy.update(owner_id: policy.store_id, owner_type: 'Spree::Store')
8
- end
5
+ policies = Arel::Table.new(:spree_policies)
6
+ connection.update(
7
+ Arel::UpdateManager.new.table(policies).set(
8
+ [[policies[:owner_id], policies[:store_id]], [policies[:owner_type], 'Spree::Store']]
9
+ )
10
+ )
9
11
 
10
12
  remove_index :spree_policies, [:store_id, :slug], unique: true, if_exists: true
11
13
  remove_index :spree_policies, [:store_id, :position], if_exists: true
@@ -5,8 +5,17 @@ class AddStoreIdToSpreeNewsletterSubscribers < ActiveRecord::Migration[7.2]
5
5
  # For spree_multi_tenant we need handle the backfill and indices there
6
6
  return if defined?(SpreeMultiTenant)
7
7
 
8
- default_store = Spree::Store.default
9
- Spree::NewsletterSubscriber.update_all(store_id: default_store.id) if default_store&.persisted?
8
+ # Read without the models: Spree::Store expects its columns (preferences,
9
+ # metadata) in the format later migrations convert them to, so loading a
10
+ # store here crashes on a database upgraded from an earlier release.
11
+ stores = Arel::Table.new(:spree_stores)
12
+ default_store_id = connection.select_value(
13
+ stores.project(stores[:id]).where(stores[:deleted_at].eq(nil).and(stores[:default].eq(true))).take(1)
14
+ )
15
+ if default_store_id.present?
16
+ subscribers = Arel::Table.new(:spree_newsletter_subscribers)
17
+ connection.update(Arel::UpdateManager.new.table(subscribers).set([[subscribers[:store_id], default_store_id]]))
18
+ end
10
19
 
11
20
  change_column_null :spree_newsletter_subscribers, :store_id, false
12
21
 
@@ -0,0 +1,16 @@
1
+ class AddAppliedTaxExemptionsToSpreeOrders < ActiveRecord::Migration[8.1]
2
+ # The exemption claims a placed order was priced with, frozen at completion
3
+ # beside its tax registration. Certificates lapse on their own date with
4
+ # nothing written to them, can be revoked, and are destroyed with their
5
+ # company — so resolving them again when a provider files or credits the sale
6
+ # is not the evidence the sale was priced with.
7
+ def change
8
+ change_table :spree_orders, bulk: true do |t|
9
+ if t.respond_to?(:jsonb)
10
+ t.jsonb :applied_tax_exemptions
11
+ else
12
+ t.json :applied_tax_exemptions
13
+ end
14
+ end
15
+ end
16
+ end
@@ -5,8 +5,18 @@ class AddStoreToSpreeOptionTypes < ActiveRecord::Migration[8.1]
5
5
  # Existing option types were installation-wide, so they belong to the
6
6
  # store that has been using them. Left nullable: a fresh install has no
7
7
  # store yet, and the association enforces presence from the model side.
8
- default_store = Spree::Store.where(default: true).first || Spree::Store.first
9
- Spree::OptionType.update_all(store_id: default_store.id) if default_store
8
+ # Read without the models: Spree::Store expects its columns (preferences,
9
+ # metadata) in the format later migrations convert them to, so loading a
10
+ # store here crashes on a database upgraded from an earlier release.
11
+ stores = Arel::Table.new(:spree_stores)
12
+ default_store_id = connection.select_value(
13
+ stores.project(stores[:id]).where(stores[:deleted_at].eq(nil))
14
+ .order(stores[:default].desc, stores[:id].asc).take(1)
15
+ )
16
+ if default_store_id.present?
17
+ option_types = Arel::Table.new(:spree_option_types)
18
+ connection.update(Arel::UpdateManager.new.table(option_types).set([[option_types[:store_id], default_store_id]]))
19
+ end
10
20
 
11
21
  # Names are unique within a store now, so two stores may each define `size`.
12
22
  remove_index :spree_option_types, :name, unique: true, if_exists: true
@@ -0,0 +1,67 @@
1
+ # OAuth 2.1 authorization server tables. Separate from any OAuth Spree
2
+ # carried before 5.4: those served password and client-credentials grants for
3
+ # the legacy API, and nothing migrates forward.
4
+ class CreateSpreeOauthTables < ActiveRecord::Migration[8.1]
5
+ def change
6
+ create_table :spree_oauth_applications do |t|
7
+ t.string :name, null: false
8
+ t.string :uid, null: false
9
+ # Null for a public client, which is every MCP client: they run in a
10
+ # browser or on a vendor's server and cannot hold a secret, which is
11
+ # why PKCE is mandatory rather than optional here.
12
+ t.string :secret
13
+ t.text :redirect_uri
14
+ t.string :scopes, null: false, default: ''
15
+ t.boolean :confidential, null: false, default: false
16
+ t.references :store, null: false, index: true
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_oauth_applications, :uid, unique: true
21
+
22
+ create_table :spree_oauth_access_grants do |t|
23
+ t.references :resource_owner, polymorphic: true, null: false, index: true
24
+ t.references :application, null: false, index: true
25
+ t.string :token, null: false
26
+ t.integer :expires_in, null: false
27
+ t.text :redirect_uri
28
+ t.string :scopes, null: false, default: ''
29
+ t.datetime :revoked_at
30
+ t.string :code_challenge
31
+ t.string :code_challenge_method
32
+ # RFC 8707: what the grant was requested for, carried to the token so
33
+ # the audience can be checked on every call. The column name is
34
+ # Doorkeeper's — its `resource_indicators_supported?` detects the
35
+ # feature by this column's presence.
36
+ t.text :resource
37
+ t.timestamps
38
+ end
39
+
40
+ add_index :spree_oauth_access_grants, :token, unique: true
41
+
42
+ create_table :spree_oauth_access_tokens do |t|
43
+ t.references :resource_owner, polymorphic: true, index: true
44
+ t.references :application, index: true
45
+ # Text rather than string: tokens are hashed, and a hash outgrows 255
46
+ # characters under some configurations.
47
+ t.text :token, null: false
48
+ t.text :refresh_token
49
+ t.integer :expires_in
50
+ t.datetime :revoked_at
51
+ t.string :scopes
52
+ t.string :previous_refresh_token, null: false, default: ''
53
+ t.text :resource
54
+ t.timestamps
55
+ end
56
+
57
+ # Hashed tokens are long, so both lookup indexes cover a prefix on MySQL,
58
+ # where an index over a full-length text column is rejected outright.
59
+ if connection.adapter_name.match?(/mysql/i)
60
+ add_index :spree_oauth_access_tokens, :token, unique: true, length: 255
61
+ add_index :spree_oauth_access_tokens, :refresh_token, unique: true, length: 255
62
+ else
63
+ add_index :spree_oauth_access_tokens, :token, unique: true
64
+ add_index :spree_oauth_access_tokens, :refresh_token, unique: true
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,9 @@
1
+ # Whether tax sits inside a price is the market's setting now, and the column is
2
+ # read once by `spree:markets:backfill_tax_inclusive`. New rates leave it empty,
3
+ # so that step, which every later upgrade runs again, never mistakes one for a
4
+ # rate whose tax was added on top. The column is dropped in 6.1.
5
+ class RemoveDefaultFromSpreeTaxRatesIncludedInPrice < ActiveRecord::Migration[8.1]
6
+ def change
7
+ change_column_default :spree_tax_rates, :included_in_price, from: false, to: nil
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ class CreateSpreeTaxExemptionReasons < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_tax_exemption_reasons do |t|
4
+ t.references :store, null: false, index: false
5
+ t.string :name, null: false
6
+ t.string :code, null: false
7
+ t.boolean :active, null: false, default: true
8
+ if t.respond_to?(:jsonb)
9
+ t.jsonb :metadata
10
+ else
11
+ t.json :metadata
12
+ end
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :spree_tax_exemption_reasons, [:store_id, :name], unique: true,
17
+ name: 'idx_tax_exemption_reasons_on_store_and_name'
18
+ add_index :spree_tax_exemption_reasons, [:store_id, :code], unique: true,
19
+ name: 'idx_tax_exemption_reasons_on_store_and_code'
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ class AddTaxExemptionReasonToSpreeTaxExemptionCertificates < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Nullable, and reason_code kept: certificates recorded before reasons
4
+ # existed are linked by `spree:upgrade:tax_exemption_reasons`, which reads
5
+ # their reason_code.
6
+ add_reference :spree_tax_exemption_certificates, :tax_exemption_reason
7
+ change_column_null :spree_tax_exemption_certificates, :reason_code, true
8
+ end
9
+ end
@@ -98,7 +98,7 @@ if tax_rate
98
98
  ).first_or_create! do |tax_line|
99
99
  tax_line.amount = 0
100
100
  tax_line.rate = tax_rate.amount
101
- tax_line.included = tax_rate.included_in_price
101
+ tax_line.included = order.tax_inclusive?
102
102
  tax_line.provider_id = 'internal'
103
103
  end
104
104
  end
@@ -0,0 +1,188 @@
1
+ module Spree
2
+ # One entry in the agent-tool registry ({Spree.admin_agent_tools}).
3
+ #
4
+ # A tool is a plain Spree object: a name, a description the model reads, a
5
+ # JSON parameter schema, the permission key that gates it, whether it
6
+ # mutates the store, and an executor. Nothing here knows which client is
7
+ # asking — the MCP server and the dashboard assistant are both adapters
8
+ # over this one registry, so a tool registered once is offered by both.
9
+ #
10
+ # Subclasses implement {#call} and declare their metadata with the class-level
11
+ # DSL:
12
+ #
13
+ # class Spree::AgentTools::SearchResources < Spree::AgentTool
14
+ # tool_name 'search_resources'
15
+ # description 'Search store records.'
16
+ # permission 'read_products'
17
+ # param :resource, description: 'What to search', required: true
18
+ #
19
+ # def call(resource:)
20
+ # # ...
21
+ # end
22
+ # end
23
+ class AgentTool
24
+ # Declarations live in class attributes rather than plain class-level
25
+ # instance variables so a subclass inherits them. Subclassing a tool to
26
+ # tweak it is the documented extension path, and with bare ivars the
27
+ # subclass would come back `mutating? == false` — writing to the store
28
+ # immediately, with no approval card, no permission gate and no argument
29
+ # validation. Inheritance here is a safety property, not a convenience.
30
+ class_attribute :declared_tool_name, instance_accessor: false
31
+ class_attribute :declared_description, instance_accessor: false
32
+ class_attribute :declared_permission, instance_accessor: false
33
+ class_attribute :declared_mutating, instance_accessor: false, default: false
34
+ class_attribute :declared_audience, instance_accessor: false, default: :admin
35
+ class_attribute :declared_params, instance_accessor: false, default: {}.freeze
36
+
37
+ class << self
38
+ # @return [String] the name the model calls this tool by
39
+ def tool_name(value = nil)
40
+ self.declared_tool_name = value if value
41
+ declared_tool_name
42
+ end
43
+
44
+ # @return [String] what the tool does, read by the model when choosing
45
+ def description(value = nil)
46
+ self.declared_description = value if value
47
+ declared_description
48
+ end
49
+
50
+ # RBAC permission key required to use this tool. A user without it never
51
+ # sees the tool in the list handed to the model.
52
+ #
53
+ # @return [String]
54
+ def permission(value = nil)
55
+ self.declared_permission = value if value
56
+ declared_permission
57
+ end
58
+
59
+ # Which surface this tool belongs to: `:admin` for the back office,
60
+ # `:store` for the storefront.
61
+ #
62
+ # Declared on the tool rather than inferred from the registry it was
63
+ # pushed into, so filing one in the wrong place is refused at
64
+ # registration. It matters most for the tools that carry no permission
65
+ # of their own — they are gated per resource, and a blank permission
66
+ # reads as "anyone may see this".
67
+ #
68
+ # @param value [Symbol, nil]
69
+ # @return [Symbol]
70
+ def audience(value = nil)
71
+ self.declared_audience = value.to_sym if value
72
+ declared_audience
73
+ end
74
+
75
+ # Declares that this tool changes store data, so the turn halts for
76
+ # human approval instead of executing.
77
+ #
78
+ # @return [void]
79
+ def mutating!
80
+ self.declared_mutating = true
81
+ end
82
+
83
+ # @return [Boolean]
84
+ def mutating?
85
+ declared_mutating == true
86
+ end
87
+
88
+ # @param name [Symbol]
89
+ # @param type [Symbol] JSON schema type
90
+ # @param description [String]
91
+ # @param required [Boolean]
92
+ # @param items [Symbol] what an array holds — a JSON Schema array without
93
+ # it is ambiguous, and a client that validates arguments against the
94
+ # schema rejects the call before the tool ever runs
95
+ # @return [void]
96
+ def param(name, type: :string, description: nil, required: false, items: nil)
97
+ # Reassigned rather than mutated, so declaring a param on a subclass
98
+ # does not reach back and change the parent's list.
99
+ self.declared_params = declared_params.merge(
100
+ name => { type: type, description: description, required: required, items: items }
101
+ ).freeze
102
+ end
103
+
104
+ # @return [Hash{Symbol => Hash}]
105
+ def params
106
+ declared_params
107
+ end
108
+ end
109
+
110
+ # @return [Spree::AgentTools::Context] who is asking, and where
111
+ attr_reader :context
112
+
113
+ # @param context [Spree::AgentTools::Context]
114
+ def initialize(context)
115
+ @context = context
116
+ end
117
+
118
+ delegate :tool_name, :description, :permission, :mutating?, :params, to: :class
119
+
120
+ # Runs the tool. Subclasses implement this with keyword arguments matching
121
+ # their declared params.
122
+ #
123
+ # @return [Hash] the result handed back to the model
124
+ def call(**)
125
+ raise NotImplementedError, 'Agent tools must implement #call'
126
+ end
127
+
128
+ # One line saying what approving this call would do. It is the entire
129
+ # approval card — the merchant decides on this sentence and nothing else,
130
+ # so every mutating tool should override it with something that reads
131
+ # like an instruction ("Set Blue Shirt to draft"), naming the record
132
+ # rather than its id.
133
+ #
134
+ # The fallback spells the arguments out rather than dumping JSON, so a
135
+ # tool that forgets to override is merely terse instead of unreadable.
136
+ #
137
+ # @param arguments [Hash]
138
+ # @return [String]
139
+ def summary(arguments)
140
+ described = arguments.map { |key, value| "#{key.to_s.humanize.downcase} #{value}" }.join(', ')
141
+
142
+ [tool_name.humanize, described.presence].compact.join(': ')
143
+ end
144
+
145
+ # Whether the asking user may use this tool at all.
146
+ #
147
+ # @return [Boolean]
148
+ def permitted?
149
+ context.permitted?(permission)
150
+ end
151
+
152
+ # A resource the map does not hold, answered with the ones it does.
153
+ #
154
+ # Every tool that takes a resource key refuses the same way: naming what is
155
+ # available is what lets the model correct itself in one step instead of
156
+ # guessing again, and a caller only ever sees resources its own credential
157
+ # may read.
158
+ #
159
+ # @param key [String]
160
+ # @return [Hash]
161
+ def unknown_resource(key)
162
+ {
163
+ error: "Unknown resource #{key.to_s.inspect}. Call describe_resource for the list.",
164
+ available: Spree::AgentTools::ResourceMap.available_for(context).map(&:key)
165
+ }
166
+ end
167
+
168
+ protected
169
+
170
+ # Refuses unless this admin may take `action` on `record`.
171
+ #
172
+ # Holding `write_products` says an admin may edit products; it does not
173
+ # say they may edit *this* one. A host app can narrow that with a
174
+ # record-level rule, and the Admin API honours it — so any tool that
175
+ # changes a record must ask here first. Returns an error the model reads
176
+ # rather than raising, so the assistant explains itself instead of the
177
+ # turn dying.
178
+ #
179
+ # @param action [Symbol]
180
+ # @param record [Object]
181
+ # @return [Hash, nil] an error result to return, or nil when allowed
182
+ def unauthorized(action, record)
183
+ return if context.can?(action, record)
184
+
185
+ { error: "You do not have permission to #{action} this #{record.class.name.demodulize.underscore.humanize.downcase}." }
186
+ end
187
+ end
188
+ end