spree_core 6.0.0.rc2 → 6.0.0.rc4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/mailers/spree/email_template_mailer.rb +5 -2
- data/app/models/concerns/spree/has_external_references.rb +7 -6
- data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/spree/cart.rb +1 -0
- data/app/models/spree/delivery_rate.rb +12 -4
- data/app/models/spree/delivery_rate_provider/base.rb +1 -1
- data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
- data/app/models/spree/fulfillment.rb +1 -0
- data/app/models/spree/gateway/bogus.rb +7 -0
- data/app/models/spree/line_item.rb +3 -8
- data/app/models/spree/oauth_access_grant.rb +11 -0
- data/app/models/spree/oauth_access_token.rb +16 -0
- data/app/models/spree/oauth_application.rb +97 -0
- data/app/models/spree/oauth_consent_request.rb +23 -0
- data/app/models/spree/oauth_protected_resource.rb +21 -0
- data/app/models/spree/oauth_redirect.rb +12 -0
- data/app/models/spree/option_type.rb +5 -0
- data/app/models/spree/option_value.rb +5 -0
- data/app/models/spree/order.rb +5 -1
- data/app/models/spree/payment.rb +14 -2
- data/app/models/spree/payment_capture_event.rb +2 -0
- data/app/models/spree/policy.rb +6 -0
- data/app/models/spree/price.rb +16 -33
- data/app/models/spree/price_rule.rb +3 -16
- data/app/models/spree/price_rules/market_rule.rb +0 -6
- data/app/models/spree/pricing_provider/base.rb +1 -1
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/purchase_order_item.rb +1 -1
- data/app/models/spree/return.rb +1 -0
- data/app/models/spree/search_provider/database.rb +1 -1
- data/app/models/spree/stock/estimator.rb +4 -20
- data/app/models/spree/store.rb +8 -0
- data/app/models/spree/tax_exemption/item_override.rb +14 -0
- data/app/models/spree/tax_exemption.rb +26 -0
- data/app/models/spree/tax_exemption_certificate.rb +13 -2
- data/app/models/spree/tax_exemption_reason.rb +62 -0
- data/app/models/spree/tax_provider/base.rb +24 -4
- data/app/models/spree/tax_provider/internal.rb +36 -29
- data/app/models/spree/tax_rate.rb +9 -35
- data/app/models/spree/translations/batch.rb +14 -4
- data/app/services/spree/carts/price_items.rb +2 -8
- data/app/services/spree/carts/split_by_seller.rb +6 -1
- data/app/services/spree/carts/update.rb +34 -7
- data/app/services/spree/email_templates/preview.rb +24 -3
- data/app/services/spree/seeds/store_resources.rb +1 -0
- data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
- data/app/services/spree/tax/resolve_exemptions.rb +11 -2
- data/app/services/spree/update_check.rb +8 -1
- data/app/tools/spree/agent_tools/create_export.rb +116 -0
- data/app/tools/spree/agent_tools/create_import.rb +117 -0
- data/app/tools/spree/agent_tools/create_media.rb +81 -0
- data/app/tools/spree/agent_tools/create_resource.rb +36 -0
- data/app/tools/spree/agent_tools/curate_products.rb +90 -0
- data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
- data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
- data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
- data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
- data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
- data/app/tools/spree/agent_tools/get_resource.rb +97 -0
- data/app/tools/spree/agent_tools/import_tool.rb +80 -0
- data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
- data/app/tools/spree/agent_tools/query_report.rb +124 -0
- data/app/tools/spree/agent_tools/resource_write.rb +99 -0
- data/app/tools/spree/agent_tools/search_resources.rb +155 -0
- data/app/tools/spree/agent_tools/set_stock.rb +52 -0
- data/app/tools/spree/agent_tools/update_resource.rb +42 -0
- data/app/tools/spree/agent_tools/upload_file.rb +104 -0
- data/app/workflows/spree/carts/complete.rb +115 -2
- data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
- data/app/workflows/spree/orders/cancel.rb +8 -0
- data/app/workflows/spree/orders/complete.rb +8 -0
- data/app/{services → workflows}/spree/orders/create.rb +9 -9
- data/app/workflows/spree/purchase_orders/create.rb +3 -0
- data/app/workflows/spree/returns/refund.rb +4 -0
- data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
- data/app/workflows/spree/variants/create.rb +7 -4
- data/config/locales/en.yml +14 -0
- data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
- data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
- data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
- data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
- data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
- data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
- data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/spree/agent_tool.rb +188 -0
- data/lib/spree/agent_tools/api_dispatch.rb +204 -0
- data/lib/spree/agent_tools/context.rb +191 -0
- data/lib/spree/agent_tools/default_catalog.rb +180 -0
- data/lib/spree/agent_tools/membership_map.rb +61 -0
- data/lib/spree/agent_tools/record_summary.rb +219 -0
- data/lib/spree/agent_tools/registry.rb +131 -0
- data/lib/spree/agent_tools/resource_map.rb +352 -0
- data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
- data/lib/spree/agent_tools/workflow_schema.rb +279 -0
- data/lib/spree/agent_tools/workflow_tool.rb +654 -0
- data/lib/spree/agent_tools.rb +24 -0
- data/lib/spree/core/configuration.rb +1 -1
- data/lib/spree/core/controller_helpers/store.rb +3 -13
- data/lib/spree/core/dependencies.rb +9 -0
- data/lib/spree/core/engine.rb +35 -1
- data/lib/spree/core/model_extensions.rb +111 -0
- data/lib/spree/core/permissions/default_catalog.rb +6 -1
- data/lib/spree/core/pricing/price_resolution.rb +4 -4
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -2
- data/lib/spree/service_module.rb +11 -0
- data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
- data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
- data/lib/tasks/markets.rake +47 -0
- data/lib/tasks/tax_exemption_reasons.rake +48 -0
- metadata +67 -7
- data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc7dbcacac8f33d70f236f52c52070ee495d7d4e366797055c17d4b119eec6d4
|
|
4
|
+
data.tar.gz: 3a2e290f02c8d3c9f367c2b4d649d94f69e58449a3ca47179ea58480a3cb1375
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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)
|
data/app/models/spree/cart.rb
CHANGED
|
@@ -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.#{
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# onto the persisted
|
|
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
|
|
@@ -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
|
|
184
|
-
#
|
|
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
|
#
|
data/app/models/spree/order.rb
CHANGED
|
@@ -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,
|
|
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
|
|
data/app/models/spree/payment.rb
CHANGED
|
@@ -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
|
data/app/models/spree/policy.rb
CHANGED
|
@@ -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
|