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.
- 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/addresses.rb +95 -32
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
- data/app/models/spree/ability.rb +8 -0
- data/app/models/spree/address.rb +6 -0
- data/app/models/spree/cart.rb +4 -6
- data/app/models/spree/company.rb +1 -1
- data/app/models/spree/data_request.rb +34 -1
- 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 +6 -2
- 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/sales_context.rb +162 -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_line.rb +5 -4
- 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/destroy.rb +7 -2
- 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 +35 -11
- data/app/services/spree/data_requests/confirm.rb +37 -0
- data/app/services/spree/data_requests/create.rb +61 -13
- 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/customers/anonymize.rb +38 -17
- data/app/workflows/spree/data_requests/fulfill.rb +20 -6
- 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 +50 -26
- 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 +18 -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 +37 -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/data_request_factory.rb +4 -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 +69 -7
- 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
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
26
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb
ADDED
|
@@ -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
|
data/db/sample_data/orders.rb
CHANGED
|
@@ -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 =
|
|
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
|