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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b2f1ca5c6b7f00a17f11bd7c7f34eac185b07acfbc58ee5cb177b0ddc804f05
4
- data.tar.gz: 521bffafb8eb7ea97c31c9e71206bb65ee5f46c453516a8365e9875b965780f7
3
+ metadata.gz: bc7dbcacac8f33d70f236f52c52070ee495d7d4e366797055c17d4b119eec6d4
4
+ data.tar.gz: 3a2e290f02c8d3c9f367c2b4d649d94f69e58449a3ca47179ea58480a3cb1375
5
5
  SHA512:
6
- metadata.gz: 49c46ec423a04b65729afe6ed2f560f49927947f615551fdf35ff5b4edeab841e689a778321c1311310125237cde58007bd0ecd56b6554b0fec2756833da8fde
7
- data.tar.gz: a8cc2d507005043e8e92e01398321e70d7af4cf687c4d9dd5bb46064c52743a42026eb5131aca5ed72735ae6389cd31423199041e13754a3a0d0a5edc3550c85
6
+ metadata.gz: ec7a1dc11ffe7aaa8b4cdd82e8e050f39d2d38fedb81c2b3a70901c3db5dbedd0b51bfad12164d2e28962b231ffff777c9ff22a884acc78e631e19a4741ab283
7
+ data.tar.gz: 4e7f9a59e472892e07722d15a598ca76dc1257c76c1cc969c77dcba8a73b7d78ceccfe88a61c9aa2fb084f633064256df3493bc2b91e79cecb86a9ca6a545259
@@ -11,11 +11,14 @@ module Spree
11
11
  # @param record_id [String, nil] the record to build sample data from
12
12
  # @param email_key [String, nil] for the layout or a partial, the email to show it in
13
13
  # @param branding [Hash] unsaved branding values
14
- def test_email(store, recipient, key, locale: nil, subject: nil, body: nil, record_id: nil, email_key: nil, branding: {})
14
+ # @param mailer_logo_signed_id [String, nil] the signed id of an unsaved, uploaded email logo
15
+ # @param mailer_logo_cleared [Boolean] whether the email logo is sent as removed
16
+ def test_email(store, recipient, key, locale: nil, subject: nil, body: nil, record_id: nil, email_key: nil, branding: {},
17
+ mailer_logo_signed_id: nil, mailer_logo_cleared: false)
15
18
  @current_store = store
16
19
  email = Spree::EmailTemplates::Preview.new(
17
20
  store: store, key: key, locale: locale, subject: subject, body: body, record_id: record_id, email_key: email_key,
18
- branding: branding
21
+ branding: branding, mailer_logo_signed_id: mailer_logo_signed_id, mailer_logo_cleared: mailer_logo_cleared
19
22
  ).call
20
23
 
21
24
  mail(to: recipient, subject: I18n.t('spree.email_templates.test_email_subject', subject: email.subject)) do |format|
@@ -11,13 +11,14 @@ module Spree
11
11
  # Scope-friendly so controllers can chain it onto an already store-scoped
12
12
  # relation: `current_store.products.with_external_id('erp', 'MAT-100')`.
13
13
  # Scoping the lookup to the store is the caller's job, exactly as with any
14
- # other id coming off a request.
14
+ # other id coming off a request. A subquery rather than a join, so a
15
+ # relation that also preloads `external_references` still loads every
16
+ # key, not only the matched one.
15
17
  scope :with_external_id, lambda { |system, external_id|
16
- joins(:external_references).where(
17
- spree_external_references: {
18
- system: Spree::ExternalReference.normalize_system(system),
19
- external_id: external_id.to_s.strip
20
- }
18
+ where(
19
+ id: Spree::ExternalReference.for_system(system).
20
+ where(resource_type: polymorphic_name, external_id: external_id.to_s.strip).
21
+ select(:resource_id)
21
22
  )
22
23
  }
23
24
  end
@@ -52,7 +52,14 @@ module Spree
52
52
  schema = value_schema(definition)
53
53
  choices = definition[:choices]
54
54
  choices = choices.call if choices.respond_to?(:call)
55
- schema['enum'] = choices.map { |choice| choice.is_a?(Symbol) ? choice.to_s : choice } if choices.present?
55
+ if choices.present?
56
+ # `{ value => label }` names values that cannot label themselves — an
57
+ # endpoint URL is no i18n key, let alone something to show a merchant.
58
+ labels = choices.to_h { |value, label| [value.to_s, label.to_s] } if choices.is_a?(Hash)
59
+ values = choices.is_a?(Hash) ? choices.keys : Array(choices)
60
+ schema['enum'] = values.map { |choice| choice.is_a?(Symbol) ? choice.to_s : choice }
61
+ schema['x-spree-choice-labels'] = labels if labels
62
+ end
56
63
  default = definition[:dynamic_default] ? nil : wire_default(default, definition)
57
64
  unset_reads_null = default.nil? && !definition[:dynamic_default]
58
65
 
@@ -28,6 +28,15 @@ module Spree
28
28
  market&.tax_provider_instance || Spree.default_tax_provider.new
29
29
  end
30
30
 
31
+ # Whether this sale's prices include tax, which its market decides. With
32
+ # no market yet (an empty cart before currency resolution) the store's
33
+ # default market answers.
34
+ #
35
+ # @return [Boolean]
36
+ def tax_inclusive?
37
+ (market || store&.default_market)&.tax_inclusive? || false
38
+ end
39
+
31
40
  # The typed inputs every +estimate+ call carries. Assembled here rather
32
41
  # than at each call site so a single-item re-estimate reaches the provider
33
42
  # with the same buyer registration and exemption evidence as a full
@@ -39,7 +48,8 @@ module Spree
39
48
  {
40
49
  tax_date: Time.current,
41
50
  tax_identifier: resolved_tax_identifier,
42
- exemptions: usable_exemptions
51
+ exemptions: usable_exemptions,
52
+ tax_inclusive: tax_inclusive?
43
53
  }
44
54
  end
45
55
 
@@ -100,15 +110,30 @@ module Spree
100
110
  pre_tax_item_amount + fulfillments.sum(:pre_tax_amount)
101
111
  end
102
112
 
103
- private
104
-
105
113
  # The resolver is swappable, so what it returns is host code. An entry
106
114
  # whose per-line overrides cannot name their line is not a narrower claim
107
115
  # but a wider one — the provider would read it as the whole order being
108
116
  # exempt — so it is dropped rather than trusted. Charging tax is the safe
109
117
  # direction: under-exempting surfaces as a customer query, over-exempting
110
118
  # is tax the merchant owes and never collected.
119
+ #
120
+ # Public because a provider filing the sale needs the same evidence core
121
+ # hands it as an +estimate+ argument: an external engine commits its
122
+ # document after placement, and resolving again there could file an
123
+ # exemption the estimate never applied.
124
+ #
125
+ # A placed order answers from its frozen snapshot for that reason, the way
126
+ # +resolved_tax_identifier+ does. Resolving live months later is not the
127
+ # same evidence: a certificate lapses on its own date with nothing written
128
+ # to it, can be revoked, and goes with its company when that is deleted —
129
+ # so a credit for an exempt sale would be filed as a consumer refund and
130
+ # declare tax the sale never collected. Orders placed before the snapshot
131
+ # existed have none, and still resolve live.
132
+ #
133
+ # @return [Array]
111
134
  def usable_exemptions
135
+ return frozen_tax_exemptions if frozen_tax_exemptions?
136
+
112
137
  resolved = Array(Spree.tax_resolve_exemptions_service.new.call(order: self).value)
113
138
 
114
139
  resolved.select do |exemption|
@@ -127,6 +152,22 @@ module Spree
127
152
  end
128
153
  end
129
154
 
155
+ private
156
+
157
+ # Carts always resolve live — a buyer's certificates legitimately change
158
+ # while they shop, and the cart is priced again each time.
159
+ #
160
+ # Only nil is an absent snapshot. An empty one records that the sale found
161
+ # no claim, and reading that as absent would let a certificate added later
162
+ # explain a sale it was never part of.
163
+ def frozen_tax_exemptions?
164
+ is_a?(Spree::Order) && completed? && !applied_tax_exemptions.nil?
165
+ end
166
+
167
+ def frozen_tax_exemptions
168
+ Array(applied_tax_exemptions).map { |snapshot| Spree::TaxExemption.from_snapshot(snapshot) }
169
+ end
170
+
130
171
  # Read through the legal entity, never the node — a division holds no
131
172
  # registrations of its own.
132
173
  def company_tax_identifier
@@ -12,6 +12,21 @@ module Spree
12
12
  const_get(:TRANSLATABLE_FIELDS)
13
13
  end
14
14
 
15
+ # The permission a caller needs in order to write this resource's
16
+ # translations.
17
+ #
18
+ # Declared rather than derived from the class name, because several
19
+ # resources are guarded by a permission that is not named after them:
20
+ # option types and values ride `write_products`, a policy rides
21
+ # `write_settings`. Deriving it produced names outside the permission
22
+ # catalog, which a credential could never hold and which the API
23
+ # therefore could not check at all.
24
+ #
25
+ # @return [String]
26
+ def translation_write_permission
27
+ "write_#{Spree::Base.polymorphic_api_type(name).pluralize}"
28
+ end
29
+
15
30
  # Maps a public API field name to the internal Mobility field, for a
16
31
  # model whose translatable column is named differently from the field the
17
32
  # API exposes. The public name is what the serializer and the translation
@@ -70,6 +85,40 @@ module Spree
70
85
  def arel_table_alias
71
86
  Arel::Table.new(translation_table_alias)
72
87
  end
88
+
89
+ # Sorts translated fields by the translation, or by the default-language
90
+ # value in the base column when the translation is missing or empty.
91
+ # Ransack sorts them by the translation alone, which leaves untranslated
92
+ # records unordered. On a DISTINCT relation the value is selected as
93
+ # well, because PostgreSQL rejects an ORDER BY the select list lacks.
94
+ #
95
+ # @return [ActiveRecord::Relation]
96
+ def with_translation_fallback_order
97
+ relation = all
98
+ return relation unless relation.order_values.any? { |node| translated_ordering?(node) }
99
+
100
+ relation = relation.select(arel_table[Arel.star]) if relation.distinct_value && relation.select_values.empty?
101
+ orders = relation.order_values.map do |node|
102
+ next node unless translated_ordering?(node)
103
+
104
+ value = Arel::Nodes::NamedFunction.new('COALESCE', [
105
+ Arel::Nodes::NamedFunction.new('NULLIF', [node.expr, Arel::Nodes.build_quoted('')]),
106
+ arel_table[node.expr.name]
107
+ ])
108
+ relation = relation.select(Arel::Nodes::As.new(value, Arel.sql('translated_sort'))) if relation.distinct_value
109
+ node.class.new(value)
110
+ end
111
+
112
+ relation.reorder(orders)
113
+ end
114
+
115
+ private
116
+
117
+ def translated_ordering?(node)
118
+ node.is_a?(Arel::Nodes::Ordering) &&
119
+ node.expr.is_a?(Mobility::Plugins::Arel::Attribute) &&
120
+ self <= node.expr.backend_class.model_class
121
+ end
73
122
  end
74
123
 
75
124
  def get_field_with_locale(locale, field_name, fallback: false)
@@ -179,6 +179,7 @@ module Spree
179
179
 
180
180
  fulfillment_ids = fulfillments.map(&:id)
181
181
  DeliveryRate.where(fulfillment_id: fulfillment_ids).delete_all
182
+ Spree::ExternalReference.where(resource_type: Spree::Fulfillment.name, resource_id: fulfillment_ids).delete_all if fulfillment_ids.any?
182
183
  fulfillments.delete_all
183
184
  fulfillment_items.reset
184
185
  return fulfillments.reload unless can_propose_deliveries?
@@ -41,7 +41,7 @@ module Spree
41
41
  return price if tax_rate.nil? || tax_amount.zero? || !tax_rate.show_rate_in_label
42
42
 
43
43
  I18n.t(
44
- "spree.shipping_rates.display_price.#{tax_rate.included_in_price? ? :including_tax : :excluding_tax}",
44
+ "spree.shipping_rates.display_price.#{tax_inclusive? ? :including_tax : :excluding_tax}",
45
45
  price: price,
46
46
  tax_amount: display_tax_amount,
47
47
  tax_rate_name: tax_rate.name
@@ -79,7 +79,7 @@ module Spree
79
79
  def tax_amount
80
80
  @tax_amount ||= if tax_rate.nil?
81
81
  BigDecimal(0)
82
- elsif tax_rate.included_in_price?
82
+ elsif tax_inclusive?
83
83
  Spree::Money::Rounding.to_currency(cost / (1 + tax_rate.amount) * tax_rate.amount, currency)
84
84
  else
85
85
  Spree::Money::Rounding.to_currency(cost * tax_rate.amount, currency)
@@ -90,14 +90,22 @@ module Spree
90
90
  #
91
91
  # @return [BigDecimal]
92
92
  def additional_tax_total
93
- tax_rate&.included_in_price? ? BigDecimal(0) : tax_amount
93
+ tax_rate && tax_inclusive? ? BigDecimal(0) : tax_amount
94
94
  end
95
95
 
96
96
  # Returns the included tax total for the shipping rate
97
97
  #
98
98
  # @return [BigDecimal]
99
99
  def included_tax_total
100
- tax_rate&.included_in_price? ? tax_amount : BigDecimal(0)
100
+ tax_rate && tax_inclusive? ? tax_amount : BigDecimal(0)
101
+ end
102
+
103
+ # Whether the cost already includes its tax, which the sale's market
104
+ # decides (Spree::Purchase::Taxation#tax_inclusive?).
105
+ #
106
+ # @return [Boolean]
107
+ def tax_inclusive?
108
+ fulfillment&.owner&.tax_inclusive? || false
101
109
  end
102
110
 
103
111
  alias tax_total tax_amount
@@ -6,7 +6,7 @@ module Spree
6
6
  # Stored as a string class name on DeliveryMethod and constantized at call
7
7
  # time; registered via Spree.delivery_rate_providers. The Estimator
8
8
  # dispatches here in place of calling the calculator directly, so
9
- # filtering, VAT gross-up, tax resolution and sorting stay in one place
9
+ # filtering, markup, tax resolution and sorting stay in one place
10
10
  # for every provider.
11
11
  #
12
12
  # External providers resolve credentials through the delivery method's
@@ -2,10 +2,11 @@ module Spree
2
2
  module DeliveryRateProvider
3
3
  # A single quote returned by a delivery rate provider.
4
4
  #
5
- # +cost+ is pre-tax and pre-VAT-gross-up: the Estimator applies
6
- # +gross_amount+ and resolves the tax rate afterwards, exactly as it does
7
- # for calculator output. Everything else is carrier metadata that flows
8
- # onto the persisted DeliveryRate.
5
+ # +cost+ is the quoted price: the Estimator adds markup and resolves the
6
+ # tax rate afterwards, exactly as it does for calculator output, and the
7
+ # market decides whether that tax is inside the cost or added on top.
8
+ # Everything else is carrier metadata that flows onto the persisted
9
+ # DeliveryRate.
9
10
  class Estimate
10
11
  include ActiveModel::Model
11
12
  include ActiveModel::Attributes
@@ -5,6 +5,7 @@ module Spree
5
5
  include Spree::DerivedNumber
6
6
  derives_number infix: 'F'
7
7
  include Spree::HasCustomFields
8
+ include Spree::HasExternalReferences
8
9
  include Spree::Metadata
9
10
  include Spree::StorePreferences
10
11
  if defined?(Spree::Security::Shipments)
@@ -70,6 +70,13 @@ module Spree
70
70
  Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
71
71
  end
72
72
 
73
+ # A made-up address, so the dashboard link can be tried without a provider.
74
+ def gateway_dashboard_payment_url(payment)
75
+ return if payment.transaction_id.blank?
76
+
77
+ "https://dashboard.bogus.example/payments/#{payment.transaction_id}"
78
+ end
79
+
73
80
  def test?
74
81
  # Test mode is not really relevant with bogus gateway (no such thing as live server)
75
82
  true
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :li # Spree-specific: line item
4
4
 
5
5
  include Spree::HasCustomFields
6
+ include Spree::HasExternalReferences
6
7
  include Spree::Metadata
7
8
 
8
9
  publishes_lifecycle_events
@@ -157,12 +158,6 @@ module Spree
157
158
 
158
159
  # Assigns the current price without saving — the caller is expected to.
159
160
  # {#recalculate_price} persists instead.
160
- #
161
- # Restatement for a tax-inclusive market happens inside
162
- # {Spree::Carts::PriceItems}: an address-less cart still has a taxing
163
- # country (its market's own), and without it a cross-border cart is taxed
164
- # for the destination at a price quoted for home, leaving the merchant to
165
- # absorb the difference.
166
161
  # persist: false — runs mid-build and from callers that save the record
167
162
  # themselves.
168
163
  def update_price
@@ -180,8 +175,8 @@ module Spree
180
175
  price_record = Spree::PricingProvider::Internal.new.price_for(context)
181
176
  return if price_record.blank?
182
177
 
183
- # Through the same writer every resolved price goes through, so the
184
- # tax-inclusive restatement inputs are stated once.
178
+ # Through the same writer every resolved price goes through, so a line
179
+ # built here is priced exactly like a resolved one.
185
180
  Spree::Carts::PriceItems.apply([[self, price_record]], persist: false)
186
181
  end
187
182
 
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ class OauthAccessGrant < Spree.base_class
3
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant
4
+
5
+ self.table_name = 'spree_oauth_access_grants'
6
+
7
+ belongs_to :application,
8
+ class_name: 'Spree::OauthApplication',
9
+ optional: false
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ module Spree
2
+ # The credential a client sends. Its resource owner is a real user, so a
3
+ # call carries that person's own permissions resolved fresh on every
4
+ # request — narrowing a role or deleting the user takes effect immediately,
5
+ # without touching the token. This is what an API key cannot do: a key is a
6
+ # standing grant with nobody behind it.
7
+ class OauthAccessToken < Spree.base_class
8
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
9
+
10
+ self.table_name = 'spree_oauth_access_tokens'
11
+
12
+ belongs_to :application,
13
+ class_name: 'Spree::OauthApplication',
14
+ optional: true
15
+ end
16
+ end
@@ -0,0 +1,97 @@
1
+ module Spree
2
+ # A registered OAuth client: an MCP client today, a marketplace app or a
3
+ # merchant-approved integration later. Clients are pre-registered rather
4
+ # than dynamically registered — RFC 7591 is deprecated, and the consumer
5
+ # clients worth supporting need no registration endpoint.
6
+ class OauthApplication < Spree.base_class
7
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application
8
+
9
+ self.table_name = 'spree_oauth_applications'
10
+
11
+ has_prefix_id :oauthapp
12
+
13
+ belongs_to :store, class_name: 'Spree::Store'
14
+
15
+ has_many :access_grants,
16
+ class_name: 'Spree::OauthAccessGrant',
17
+ foreign_key: :application_id,
18
+ dependent: :destroy
19
+ has_many :access_tokens,
20
+ class_name: 'Spree::OauthAccessToken',
21
+ foreign_key: :application_id,
22
+ dependent: :destroy
23
+
24
+ # Tokens that can still produce access.
25
+ #
26
+ # Not simply `not_expired`: an access token lasts hours while the refresh
27
+ # token beside it lasts far longer, so a client whose access has lapsed
28
+ # can mint another at will. Listing only unexpired access would hide a
29
+ # connection from the screen that exists to revoke it — the one place a
30
+ # merchant can actually stop it.
31
+ has_many :live_access_tokens,
32
+ -> { where(revoked_at: nil) },
33
+ class_name: 'Spree::OauthAccessToken',
34
+ foreign_key: :application_id,
35
+ inverse_of: :application,
36
+ dependent: nil
37
+
38
+ # Whether anything can still reach the store with this registration.
39
+ #
40
+ # The screen lists every registered client, so a row with no live token
41
+ # may be one nobody has connected yet or one whose access was revoked —
42
+ # and an empty permission list reads the same either way. A merchant
43
+ # pressing Revoke has to see that it worked.
44
+ #
45
+ # @return [Boolean]
46
+ def connected?
47
+ live_access_tokens.any?
48
+ end
49
+
50
+ # What this application may currently do, as permission keys.
51
+ #
52
+ # Read from the tokens rather than the registration: a client is
53
+ # registered once and granted per consent, so the registration's own
54
+ # `scopes` column stays empty.
55
+ #
56
+ # @return [Array<String>]
57
+ def live_token_scopes
58
+ live_access_tokens.flat_map { |token| token.scopes.to_a }.uniq.sort
59
+ end
60
+
61
+ # When this application last obtained a token that still works.
62
+ #
63
+ # Not the last call it made: a token is minted once and then used for
64
+ # hours, and nothing records a call. Reporting this as "last used" would
65
+ # let a merchant read an actively running agent as idle, which is the
66
+ # opposite of what the connected-apps screen is for.
67
+ #
68
+ # @return [ActiveSupport::TimeWithZone, nil]
69
+ def last_authorized_at
70
+ live_access_tokens.map(&:created_at).max
71
+ end
72
+
73
+ # Who approved this application's access, and when.
74
+ #
75
+ # Read from the oldest live token, because that is the consent still in
76
+ # force — a later token refreshes an existing grant rather than replacing
77
+ # who gave it. On the screen that decides whether to revoke, "Claude can
78
+ # read your orders" is only half the story without the person who allowed
79
+ # it.
80
+ #
81
+ # @return [Object, nil] a {Spree.admin_user_class} record
82
+ def authorized_by
83
+ oldest_live_token&.resource_owner
84
+ end
85
+
86
+ # @return [ActiveSupport::TimeWithZone, nil]
87
+ def authorized_at
88
+ oldest_live_token&.created_at
89
+ end
90
+
91
+ private
92
+
93
+ def oldest_live_token
94
+ live_access_tokens.min_by(&:created_at)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ # What a client is asking a merchant to approve, as the consent screen
3
+ # needs it. Never persisted — approving it writes an
4
+ # {Spree::OauthAccessGrant} instead.
5
+ class OauthConsentRequest
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :client_name, :string
10
+ # Every permission the requested scopes imply, expanded, so the screen
11
+ # lists what is actually being handed over rather than the shorthand the
12
+ # client asked for.
13
+ attribute :scopes, default: -> { [] }
14
+ # Of those, the ones this person may hand over. A grant can only narrow
15
+ # the approver's own authority, so anything they do not hold is not
16
+ # offered.
17
+ attribute :grantable_scopes, default: -> { [] }
18
+ attribute :resource, :string
19
+ attribute :redirect_uri, :string
20
+
21
+ validates :client_name, :redirect_uri, presence: true
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ # RFC 9728 metadata: which authorization server guards a protected surface
3
+ # and what it will accept. Never persisted — assembled per request, since
4
+ # the origin a client reached is the one it can use.
5
+ class OauthProtectedResource
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :resource, :string
10
+ attribute :authorization_servers, default: -> { [] }
11
+ # Exactly what the authorization server accepts. Advertising one scope
12
+ # more than it knows turns every connection into "the requested scope is
13
+ # invalid", because a client with no scope challenge asks for everything
14
+ # listed here.
15
+ attribute :scopes_supported, default: -> { [] }
16
+ attribute :bearer_methods_supported, default: -> { ['header'] }
17
+ attribute :resource_documentation, :string
18
+
19
+ validates :resource, presence: true
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ # Where to send the browser once the merchant has decided, carrying either
3
+ # the authorization code or the refusal. Never persisted.
4
+ class OauthRedirect
5
+ include ActiveModel::Model
6
+ include ActiveModel::Attributes
7
+
8
+ attribute :redirect_uri, :string
9
+
10
+ validates :redirect_uri, presence: true
11
+ end
12
+ end
@@ -12,6 +12,11 @@ module Spree
12
12
 
13
13
  TRANSLATABLE_FIELDS = Spree::LabelTranslatable::TRANSLATABLE_FIELDS
14
14
 
15
+ # Option types are managed under the catalog, so the products permission guards them — see Admin::OptionTypesController.
16
+ def self.translation_write_permission
17
+ 'write_products'
18
+ end
19
+
15
20
  # Option values are translated alongside their type — the translations
16
21
  # endpoint nests their matrices so the editor fetches both in one read.
17
22
  def self.translatable_children
@@ -7,6 +7,11 @@ module Spree
7
7
  include Spree::Metadata
8
8
  include Spree::LabelTranslatable
9
9
 
10
+ # Values are written through their option type, so they ride the same permission.
11
+ def self.translation_write_permission
12
+ 'write_products'
13
+ end
14
+
10
15
  TRANSLATABLE_FIELDS = Spree::LabelTranslatable::TRANSLATABLE_FIELDS
11
16
 
12
17
  #
@@ -51,9 +51,10 @@ module Spree
51
51
  include Spree::NumberIdentifier
52
52
 
53
53
  publishes_lifecycle_events
54
- publishes_events :approved, :canceled, :delivered, :fulfilled, :paid, :placed, :shipped,
54
+ publishes_events :approved, :canceled, :delivered, :fulfilled, :paid, :placed,
55
55
  :resend_confirmation_email, :resend_digital_links_email
56
56
  publishes_event :completed, deprecated_alias_of: 'order.placed'
57
+ publishes_event :shipped, deprecated_alias_of: 'order.fulfilled'
57
58
  include Spree::HasCustomFields
58
59
  include Spree::Metadata
59
60
  include Spree::HasExternalReferences
@@ -920,6 +921,9 @@ module Spree
920
921
 
921
922
  shipment_ids = fulfillments.map(&:id)
922
923
  DeliveryRate.where(fulfillment_id: shipment_ids).delete_all
924
+ # A connector's key for a fulfillment that is going away would otherwise
925
+ # outlive it and block the key from naming the rebuilt one.
926
+ Spree::ExternalReference.where(resource_type: Spree::Fulfillment.name, resource_id: shipment_ids).delete_all if shipment_ids.any?
923
927
 
924
928
  fulfillments.delete_all
925
929
 
@@ -326,16 +326,28 @@ module Spree
326
326
  true
327
327
  end
328
328
 
329
+ # Matches +Payment.risky+: any result other than a match counts, whatever
330
+ # message the gateway sent with it.
329
331
  def is_cvv_risky?
330
332
  return false if cvv_response_code == 'M'
331
333
  return false if cvv_response_code.nil?
332
- return false if cvv_response_message.present?
333
334
 
334
335
  true
335
336
  end
336
337
 
338
+ # The payment's page in the provider's dashboard. Only https links are
339
+ # returned, since staff open it from the admin and a gateway may build it
340
+ # from values the provider sent back.
341
+ #
342
+ # @return [String, nil]
337
343
  def gateway_dashboard_payment_url
338
- payment_method.try(:gateway_dashboard_payment_url, self)
344
+ url = payment_method.try(:gateway_dashboard_payment_url, self)
345
+ return if url.blank?
346
+
347
+ uri = URI.parse(url)
348
+ url if uri.is_a?(URI::HTTPS) && uri.host.present?
349
+ rescue URI::InvalidURIError
350
+ nil
339
351
  end
340
352
 
341
353
  def captured_amount
@@ -4,6 +4,8 @@ module Spree
4
4
 
5
5
  belongs_to :payment, class_name: 'Spree::Payment'
6
6
 
7
+ delegate :currency, to: :payment
8
+
7
9
  def display_amount
8
10
  Spree::Money.new(amount, currency: payment.currency)
9
11
  end
@@ -4,6 +4,12 @@ module Spree
4
4
 
5
5
  extend FriendlyId
6
6
  include Spree::TranslatableResource
7
+
8
+ # Policies are store settings — see Admin::PoliciesController.
9
+ def self.translation_write_permission
10
+ 'write_settings'
11
+ end
12
+
7
13
  include Spree::SanitizableRichText
8
14
 
9
15
  UNIQUENESS_SCOPE = %i[owner_id owner_type].freeze