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
|
@@ -9,6 +9,10 @@ module Spree
|
|
|
9
9
|
# zone_id any more, but the column stays in the database through 6.0: it is
|
|
10
10
|
# what `spree:migrate_tax_zones` converts from, and that task runs after
|
|
11
11
|
# db:migrate. The physical drop lands in 6.1.
|
|
12
|
+
#
|
|
13
|
+
# The same goes for included_in_price: whether tax sits inside a price is
|
|
14
|
+
# the market's choice (Spree::Market#tax_inclusive), and the column is read
|
|
15
|
+
# only by `spree:markets:backfill_tax_inclusive`, which clears it.
|
|
12
16
|
|
|
13
17
|
acts_as_paranoid
|
|
14
18
|
|
|
@@ -52,15 +56,10 @@ module Spree
|
|
|
52
56
|
}
|
|
53
57
|
# The same question asked with an address in hand.
|
|
54
58
|
scope :for_address, ->(address) { for_jurisdiction(address&.country_code, address&.state_code) }
|
|
55
|
-
# Country-wide: unlike for_jurisdiction(country_code, nil) this leaves the
|
|
56
|
-
# state unconstrained, so it sums a country's state-level rates too. Used
|
|
57
|
-
# where the state is irrelevant — backing VAT out of a gross price.
|
|
58
|
-
scope :for_country, ->(country_code) { where(country_code: [country_code.presence, nil].uniq) }
|
|
59
59
|
scope :for_tax_category,
|
|
60
60
|
->(category) { where(tax_category_id: category.try(:id)) }
|
|
61
|
-
scope :included_in_price, -> { where(included_in_price: true) }
|
|
62
61
|
|
|
63
|
-
self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id
|
|
62
|
+
self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id name]
|
|
64
63
|
ransack_alias :rate, :amount
|
|
65
64
|
search_by :name
|
|
66
65
|
|
|
@@ -92,38 +91,13 @@ module Spree
|
|
|
92
91
|
self.rate_percent = value
|
|
93
92
|
end
|
|
94
93
|
|
|
95
|
-
# The included-in-price rate to back out of a gross price.
|
|
96
|
-
#
|
|
97
|
-
# @param options [Hash] +:country+ (or +:address+) and +:tax_category+
|
|
98
|
-
# @return [BigDecimal] 0 when the jurisdiction or category is unknown
|
|
99
|
-
def self.included_tax_amount_for(options)
|
|
100
|
-
# +:country+ is a documented override point and the VAT path still hands
|
|
101
|
-
# it a Spree::Country, so take the code off whichever arrives.
|
|
102
|
-
country = options[:country]
|
|
103
|
-
country_code = country.try(:iso) || country.presence || options[:address]&.country_code
|
|
104
|
-
return 0 unless country_code && options[:tax_category]
|
|
105
|
-
|
|
106
|
-
scope = options[:address] ? for_address(options[:address]) : for_country(country_code)
|
|
107
|
-
# Per-store tax configuration is the norm since 6.0, so without this the
|
|
108
|
-
# sum would add up every store's rate for the same country.
|
|
109
|
-
store = options[:store] || Spree::Current.store
|
|
110
|
-
scope = scope.for_store(store) if store
|
|
111
|
-
scope.included_in_price.for_tax_category(options[:tax_category]).sum(:amount)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def included?
|
|
115
|
-
included_in_price
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def additional?
|
|
119
|
-
!included_in_price
|
|
120
|
-
end
|
|
121
|
-
|
|
122
94
|
# The customer-facing label snapshotted onto TaxLine rows.
|
|
123
95
|
#
|
|
96
|
+
# @param included [Boolean] whether the tax sits inside the price, which
|
|
97
|
+
# the sale's market decides (Spree::Market#tax_inclusive)
|
|
124
98
|
# @return [String]
|
|
125
|
-
def adjustment_label
|
|
126
|
-
I18n.t "spree.adjustment_labels.tax_rates.#{
|
|
99
|
+
def adjustment_label(included: false)
|
|
100
|
+
I18n.t "spree.adjustment_labels.tax_rates.#{included ? :including_tax : :excluding_tax}",
|
|
127
101
|
name: name.presence || tax_category.name,
|
|
128
102
|
amount: amount_for_label
|
|
129
103
|
end
|
|
@@ -45,12 +45,22 @@ module Spree
|
|
|
45
45
|
@records = []
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Distinct
|
|
49
|
-
#
|
|
50
|
-
#
|
|
48
|
+
# Distinct write permissions a caller needs to authorize this batch —
|
|
49
|
+
# one per resource type present.
|
|
50
|
+
#
|
|
51
|
+
# Each resource names its own, because several are guarded by a
|
|
52
|
+
# permission not named after them: an option type rides
|
|
53
|
+
# `write_products`, a policy rides `write_settings`. Deriving the name
|
|
54
|
+
# from the type produced strings outside the permission catalog, which
|
|
55
|
+
# no credential could hold and which the API therefore skipped — so a
|
|
56
|
+
# grant narrowed to reading could still rewrite a store's policy text.
|
|
57
|
+
#
|
|
58
|
+
# A type that resolves to nothing is left out; `resolve_record!` is what
|
|
59
|
+
# reports it, one entry at a time with its index.
|
|
60
|
+
#
|
|
51
61
|
# @return [Array<String>]
|
|
52
62
|
def required_scopes
|
|
53
|
-
@entries.
|
|
63
|
+
@entries.filter_map { |entry| resource_class(entry[:resource_type])&.translation_write_permission }.uniq
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# Upserts every entry in one transaction. Yields each resolved record
|
|
@@ -71,20 +71,14 @@ module Spree
|
|
|
71
71
|
resolutions = Array(resolutions)
|
|
72
72
|
return if resolutions.empty?
|
|
73
73
|
|
|
74
|
-
# One derivation for the batch: every line in a resolution set belongs
|
|
75
|
-
# to the same cart, and tax_address / tax_country walk associations and
|
|
76
|
-
# a store preference that would otherwise be re-read per line.
|
|
77
|
-
owner = resolutions.first.first.owner
|
|
78
|
-
vat_inputs = { address: owner&.tax_address, country: owner&.tax_country, market: owner&.market }
|
|
79
|
-
|
|
80
74
|
resolutions.each do |line_item, price|
|
|
81
75
|
# Probe-based flows resolve against stand-ins, so the guard has to
|
|
82
76
|
# hold at the write too.
|
|
83
77
|
next if line_item.manual_price?
|
|
84
78
|
|
|
85
|
-
amount = price.
|
|
79
|
+
amount = price.price
|
|
86
80
|
# Marked before the blank guard: the provider has already answered,
|
|
87
|
-
# so a
|
|
81
|
+
# so a price that comes back blank must still not send the
|
|
88
82
|
# after_save callback back to the provider from inside the caller's
|
|
89
83
|
# transaction. Only the assign-for-a-coming-save case needs it — a
|
|
90
84
|
# persisted write goes through update_columns and fires no callbacks.
|
|
@@ -34,10 +34,15 @@ module Spree
|
|
|
34
34
|
# recorded about the purchase (copied from the cart), so every child gets
|
|
35
35
|
# the same hash — and so does the group. Once split, each order's
|
|
36
36
|
# metadata is its own: edits never propagate between siblings.
|
|
37
|
+
#
|
|
38
|
+
# `applied_tax_exemptions` rides along, or a sibling resolves again and
|
|
39
|
+
# takes override ids that match nothing it owns. Line items keep their ids
|
|
40
|
+
# here; a divided fulfillment and its money rows do not, so an override
|
|
41
|
+
# naming one is stale — latent while nothing supplies overrides.
|
|
37
42
|
CARRIED_TO_SIBLING = %w[
|
|
38
43
|
email currency locale market_id channel_id company_id
|
|
39
44
|
customer_id token accept_marketing preferred_stock_location_id
|
|
40
|
-
customer_note last_ip_address po_number metadata
|
|
45
|
+
customer_note last_ip_address po_number metadata applied_tax_exemptions
|
|
41
46
|
].freeze
|
|
42
47
|
|
|
43
48
|
# @param order [Spree::Order] the paid draft order, adopted as the
|
|
@@ -28,10 +28,11 @@ module Spree
|
|
|
28
28
|
success(cart)
|
|
29
29
|
rescue ActiveRecord::RecordNotFound
|
|
30
30
|
raise
|
|
31
|
-
rescue Spree::Pricing::PriceResolution::ProviderUnavailable => e
|
|
32
|
-
# A strict store could not confirm a price
|
|
33
|
-
# back, so the address change is
|
|
34
|
-
# saving a destination whose prices
|
|
31
|
+
rescue Spree::Pricing::PriceResolution::ProviderUnavailable, Spree::Tax::ProviderError => e
|
|
32
|
+
# A strict store could not confirm a price, or the tax engine did not
|
|
33
|
+
# produce tax. The transaction has rolled back, so the address change is
|
|
34
|
+
# not committed either — better than saving a destination whose prices
|
|
35
|
+
# or tax nobody stood behind.
|
|
35
36
|
#
|
|
36
37
|
# The in-memory cart still holds the rolled-back attributes, though, so
|
|
37
38
|
# a caller that renders the object it passed in would show the customer
|
|
@@ -48,7 +49,7 @@ module Spree
|
|
|
48
49
|
end
|
|
49
50
|
cart.warnings |= warnings if warnings.present?
|
|
50
51
|
|
|
51
|
-
failure(cart,
|
|
52
|
+
failure(cart, { code: unavailable_code_for(e), message: unavailable_message_for(e) })
|
|
52
53
|
rescue ActiveRecord::RecordInvalid => e
|
|
53
54
|
failure(cart, e.record.errors.full_messages.to_sentence)
|
|
54
55
|
rescue StandardError => e
|
|
@@ -229,6 +230,29 @@ module Spree
|
|
|
229
230
|
end
|
|
230
231
|
end
|
|
231
232
|
|
|
233
|
+
# Three ways an external service can leave a sale unpriced, and a
|
|
234
|
+
# customer can only act on what they are told. A refused calculation
|
|
235
|
+
# carries the engine's own words, because "the postcode is not valid for
|
|
236
|
+
# that state" is something they can fix and "please try again" is not.
|
|
237
|
+
# The same split the API's own handler makes: a refusal names something
|
|
238
|
+
# to fix, an outage is worth retrying, and a price that could not be
|
|
239
|
+
# confirmed is neither tax case.
|
|
240
|
+
def unavailable_code_for(error)
|
|
241
|
+
case error
|
|
242
|
+
when Spree::Tax::CalculationRefused then 'tax_calculation_refused'
|
|
243
|
+
when Spree::Tax::ProviderError then 'tax_provider_unavailable'
|
|
244
|
+
else 'provider_unavailable'
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def unavailable_message_for(error)
|
|
249
|
+
case error
|
|
250
|
+
when Spree::Tax::CalculationRefused then error.message.to_s
|
|
251
|
+
when Spree::Tax::ProviderError then I18n.t('spree.cart_line_item.tax_unavailable')
|
|
252
|
+
else I18n.t('spree.cart_line_item.pricing_unavailable')
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
232
256
|
# Three-way dispatch on the cart→checkout transition:
|
|
233
257
|
# entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
|
|
234
258
|
# A failed Reserve raises so the enclosing transaction rolls back and the
|
|
@@ -256,10 +280,13 @@ module Spree
|
|
|
256
280
|
else
|
|
257
281
|
cart.recalculate_totals!
|
|
258
282
|
end
|
|
259
|
-
rescue Spree::Pricing::PriceResolution::ProviderUnavailable
|
|
283
|
+
rescue Spree::Pricing::PriceResolution::ProviderUnavailable, Spree::Tax::ProviderError
|
|
260
284
|
# Deliberately not swallowed like the rest: a strict store refusing to
|
|
261
285
|
# price must roll the whole update back, so `call` sees it and the
|
|
262
|
-
# address change does not commit around unconfirmed prices.
|
|
286
|
+
# address change does not commit around unconfirmed prices. A tax engine
|
|
287
|
+
# that cannot answer is the same problem one column over — swallowing it
|
|
288
|
+
# showed the customer a total with tax missing and saved the address
|
|
289
|
+
# anyway, so the mistake only surfaced when they tried to pay.
|
|
263
290
|
raise
|
|
264
291
|
rescue StandardError => e
|
|
265
292
|
Rails.error.report(e, context: { order_id: cart.id }, source: 'spree.checkout')
|
|
@@ -23,8 +23,11 @@ module Spree
|
|
|
23
23
|
# @param email_key [String, nil] for the layout or a partial, the email to show it in
|
|
24
24
|
# @param strict [Boolean] whether an unknown variable raises
|
|
25
25
|
# @param branding [Hash] unsaved branding values (see Spree::Emails::Branding)
|
|
26
|
+
# @param mailer_logo_signed_id [String, nil] the signed id of an unsaved, uploaded email logo
|
|
27
|
+
# @param mailer_logo_cleared [Boolean] whether the email logo is previewed as removed
|
|
26
28
|
def initialize(store:, key:, locale: Spree::EmailTemplate::ANY_LOCALE, subject: nil, body: nil,
|
|
27
|
-
record_id: nil, email_key: nil, strict: false, branding: {}
|
|
29
|
+
record_id: nil, email_key: nil, strict: false, branding: {}, mailer_logo_signed_id: nil,
|
|
30
|
+
mailer_logo_cleared: false)
|
|
28
31
|
@store = store
|
|
29
32
|
@key = key
|
|
30
33
|
@locale = locale.presence || Spree::EmailTemplate::ANY_LOCALE
|
|
@@ -34,16 +37,20 @@ module Spree
|
|
|
34
37
|
@email_key = email_key
|
|
35
38
|
@strict = strict
|
|
36
39
|
@branding = branding
|
|
40
|
+
@mailer_logo_signed_id = mailer_logo_signed_id
|
|
41
|
+
@mailer_logo_cleared = mailer_logo_cleared
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# @return [Spree::Emails::RenderedEmail]
|
|
40
45
|
# @raise [Spree::EmailTemplates::NoSampleRecord] when the store has no record to build the sample from
|
|
46
|
+
# @raise [ActiveRecord::RecordInvalid] when the unsaved logo is one saving the store would refuse
|
|
47
|
+
# @raise [ActiveSupport::MessageVerifier::InvalidSignature] when the unsaved logo's signed id is not valid
|
|
41
48
|
def call
|
|
42
49
|
sample = email.sample_class.new(store: @store, record_id: @record_id)
|
|
43
50
|
|
|
44
51
|
in_locale do
|
|
45
|
-
renderer = Spree::Emails::Renderer.new(resolver: resolver, store:
|
|
46
|
-
branding: @branding)
|
|
52
|
+
renderer = Spree::Emails::Renderer.new(resolver: resolver, store: email_store, currency: sample.currency,
|
|
53
|
+
strict: @strict, branding: @branding)
|
|
47
54
|
sample_variables = sample.variables
|
|
48
55
|
@variables = renderer.variables(sample_variables)
|
|
49
56
|
@rendered = renderer.render(resolver.find(email.key), sample_variables)
|
|
@@ -66,6 +73,20 @@ module Spree
|
|
|
66
73
|
Spree.editable_email_templates[@key] || raise(ArgumentError, "#{@key} is not an editable email template")
|
|
67
74
|
end
|
|
68
75
|
|
|
76
|
+
# The store as the email shows it. An unsaved logo upload or removal is
|
|
77
|
+
# applied to a copy that is never saved, and checked as saving the store
|
|
78
|
+
# checks it, so the preview never shows a logo the Save button refuses.
|
|
79
|
+
def email_store
|
|
80
|
+
return @store if @mailer_logo_signed_id.nil? && !@mailer_logo_cleared
|
|
81
|
+
|
|
82
|
+
@email_store ||= @store.class.find(@store.id).tap do |store|
|
|
83
|
+
store.mailer_logo = @mailer_logo_cleared ? nil : @mailer_logo_signed_id
|
|
84
|
+
store.validate
|
|
85
|
+
(store.errors.attribute_names - [:mailer_logo]).each { |attribute| store.errors.delete(attribute) }
|
|
86
|
+
raise ActiveRecord::RecordInvalid, store if store.errors.any?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
69
90
|
def resolver
|
|
70
91
|
@resolver ||= Spree::Emails::TemplateResolver.for_mailers(store: @store, locale: language, drafts: drafts)
|
|
71
92
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Seeds
|
|
3
|
+
# The reasons a business can give for buying without tax. Neutral codes
|
|
4
|
+
# that Spree's own engine keeps for reference; a store using an engine
|
|
5
|
+
# that reads them (Avalara) changes each code to that engine's.
|
|
6
|
+
class TaxExemptionReasons
|
|
7
|
+
prepend Spree::ServiceModule::Base
|
|
8
|
+
include StoreScoped
|
|
9
|
+
|
|
10
|
+
REASONS = {
|
|
11
|
+
'RESALE' => 'Resale',
|
|
12
|
+
'GOVERNMENT' => 'Government',
|
|
13
|
+
'CHARITABLE' => 'Charitable organization',
|
|
14
|
+
'EDUCATIONAL' => 'Educational institution',
|
|
15
|
+
'MANUFACTURING' => 'Manufacturing',
|
|
16
|
+
'AGRICULTURAL' => 'Agricultural'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def seed(store)
|
|
22
|
+
REASONS.each do |code, name|
|
|
23
|
+
store.tax_exemption_reasons.find_or_create_by_code_or_name!(code: code, name: name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -29,9 +29,18 @@ module Spree
|
|
|
29
29
|
return success([]) if company.nil? || address.nil?
|
|
30
30
|
|
|
31
31
|
success(
|
|
32
|
-
company.tax_exemption_certificates.active.for_address(address).
|
|
32
|
+
company.tax_exemption_certificates.active.for_address(address).includes(:tax_exemption_reason).filter_map do |certificate|
|
|
33
|
+
reason = certificate.tax_exemption_reason
|
|
34
|
+
# A certificate recorded before reasons existed keeps its old code
|
|
35
|
+
# until `spree:upgrade:tax_exemption_reasons` links it. One with
|
|
36
|
+
# neither (its reason row removed outside the app) is skipped
|
|
37
|
+
# rather than failing tax for every order of the company.
|
|
38
|
+
reason_code = reason&.code || certificate.reason_code
|
|
39
|
+
next if reason_code.blank?
|
|
40
|
+
|
|
33
41
|
Spree::TaxExemption.new(
|
|
34
|
-
reason_code:
|
|
42
|
+
reason_code: reason_code,
|
|
43
|
+
reason_name: reason&.name,
|
|
35
44
|
certificate_number: certificate.certificate_number,
|
|
36
45
|
country_code: certificate.country_code,
|
|
37
46
|
state_code: certificate.state_code
|
|
@@ -34,9 +34,16 @@ module Spree
|
|
|
34
34
|
LARGEST_BUCKET = '100k+'.freeze
|
|
35
35
|
|
|
36
36
|
class << self
|
|
37
|
+
# Always false in the test environment and on CI servers (which set
|
|
38
|
+
# +CI+), so no test run ever reaches spreecloud.io; specs stub the
|
|
39
|
+
# request instead.
|
|
40
|
+
#
|
|
37
41
|
# @return [Boolean] false when the operator turned the check off
|
|
38
42
|
def enabled?
|
|
39
|
-
|
|
43
|
+
!Rails.env.test? &&
|
|
44
|
+
!env_flag?('CI') &&
|
|
45
|
+
Spree::Config.update_check_enabled &&
|
|
46
|
+
!env_flag?('SPREE_UPDATE_CHECK_DISABLED')
|
|
40
47
|
end
|
|
41
48
|
|
|
42
49
|
# When false, the check still runs but sends the running version only.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Exports records to a CSV the merchant can download.
|
|
4
|
+
#
|
|
5
|
+
# Mutating not because it changes the store — it does not — but because
|
|
6
|
+
# it produces a file of store data and emails a link to it. That leaves
|
|
7
|
+
# the building, so a merchant decides, not the model.
|
|
8
|
+
class CreateExport < Spree::AgentTool
|
|
9
|
+
tool_name 'create_export'
|
|
10
|
+
description 'Export records to a CSV file. Say which kind of records and, ' \
|
|
11
|
+
'optionally, the same filters search_resources accepts. The merchant ' \
|
|
12
|
+
'approves before the file is generated.'
|
|
13
|
+
# Exports are gated on the READ scope of what is being exported — the
|
|
14
|
+
# Admin API treats an export as a read, because that is what it is. That
|
|
15
|
+
# is a per-resource question, so it is answered in `call` rather than
|
|
16
|
+
# here: a class-level key would hide the tool from a caller who may
|
|
17
|
+
# export orders but not products.
|
|
18
|
+
|
|
19
|
+
# Exports whose rows carry a bearer instrument — a code that spends
|
|
20
|
+
# money for whoever reads it. Refused outright rather than stripped,
|
|
21
|
+
# because an export of gift cards without their codes answers nothing
|
|
22
|
+
# the merchant asked for. `Spree::Mcp::Exports` reads this list too, so
|
|
23
|
+
# a file made before this existed stays unreadable.
|
|
24
|
+
BEARER_EXPORTS = %w[gift_cards coupon_codes].freeze
|
|
25
|
+
|
|
26
|
+
permission nil
|
|
27
|
+
mutating!
|
|
28
|
+
|
|
29
|
+
param :resource, description: 'What to export: products, orders, customers',
|
|
30
|
+
required: true
|
|
31
|
+
param :filters, type: :object,
|
|
32
|
+
description: 'Optional Ransack filters, e.g. {"status_eq": "active"}'
|
|
33
|
+
|
|
34
|
+
def call(resource:, filters: nil)
|
|
35
|
+
export_class = export_class_for(resource)
|
|
36
|
+
return unknown_resource(resource) if export_class.nil?
|
|
37
|
+
|
|
38
|
+
required = "read_#{export_class.required_scope}"
|
|
39
|
+
unless context.holds?(required)
|
|
40
|
+
return { error: "You do not have permission to export #{resource.to_s.tr('_', ' ')}." }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Refused here rather than at read, so the merchant is not left with a
|
|
44
|
+
# generated file of live codes waiting to be collected.
|
|
45
|
+
if Spree::AgentTools::CreateExport::BEARER_EXPORTS.include?(resource.to_s)
|
|
46
|
+
return { error: "#{resource.to_s.tr('_', ' ').capitalize} carry codes that spend money for " \
|
|
47
|
+
'whoever reads them, so they cannot be exported here. Download them from ' \
|
|
48
|
+
'the dashboard instead.' }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
export = export_class.new(
|
|
52
|
+
store: context.store,
|
|
53
|
+
# For a signed-in admin the export builds an ability from this user,
|
|
54
|
+
# so the file holds exactly the records that admin could see,
|
|
55
|
+
# record-level rules included. For an API key there is no user and
|
|
56
|
+
# no ability: the scope check above is the whole filter, which is
|
|
57
|
+
# how the Admin API treats key requests everywhere else.
|
|
58
|
+
user: context.user,
|
|
59
|
+
format: 'csv',
|
|
60
|
+
search_params: filters.presence
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
refusal = unauthorized(:create, export)
|
|
64
|
+
return refusal if refusal
|
|
65
|
+
|
|
66
|
+
# Saving publishes `export.created`, which is what actually enqueues
|
|
67
|
+
# generation. (`generate_async` hands a raw id to a job that expects a
|
|
68
|
+
# prefixed one — the subscriber is the working path.)
|
|
69
|
+
return { error: export.errors.full_messages.to_sentence } unless export.save
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
ok: true,
|
|
73
|
+
id: export.prefixed_id,
|
|
74
|
+
resource: resource.to_s,
|
|
75
|
+
# Generation is a background job, so there is no file yet. Naming
|
|
76
|
+
# the tool that finishes the job keeps the model from sending the
|
|
77
|
+
# merchant to the dashboard for something it can fetch itself.
|
|
78
|
+
# Naming the MCP resource matters: a client that can read one will
|
|
79
|
+
# otherwise never learn the file is there, and the model reports a
|
|
80
|
+
# download path to a merchant instead of reading the rows itself.
|
|
81
|
+
resource_uri: "spree+export://#{export.prefixed_id}",
|
|
82
|
+
message: 'The export is being prepared. Once it is ready you can read its contents ' \
|
|
83
|
+
"from the MCP resource spree+export://#{export.prefixed_id}, or call " \
|
|
84
|
+
"get_resource (resource: \"exports\", id: \"#{export.prefixed_id}\") for its " \
|
|
85
|
+
'status and a download_url to hand the merchant.'
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def summary(arguments)
|
|
90
|
+
label = arguments[:resource].to_s.tr('_', ' ')
|
|
91
|
+
return "Export all #{label}" if arguments[:filters].blank?
|
|
92
|
+
|
|
93
|
+
filters = arguments[:filters].keys.map { |key| key.to_s.tr('_', ' ') }.join(', ')
|
|
94
|
+
"Export #{label} filtered by #{filters}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Only the export kinds Spree actually ships, resolved by name rather
|
|
100
|
+
# than constantized from model input.
|
|
101
|
+
def export_class_for(resource)
|
|
102
|
+
key = resource.to_s.downcase
|
|
103
|
+
Spree::Export.descendants.find do |klass|
|
|
104
|
+
klass.name.demodulize.underscore == key
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unknown_resource(resource)
|
|
109
|
+
{
|
|
110
|
+
error: "Cannot export #{resource.inspect}",
|
|
111
|
+
available: Spree::Export.descendants.map { |k| k.name.demodulize.underscore }.sort
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Starts a CSV import from a file the merchant provided.
|
|
4
|
+
#
|
|
5
|
+
# Gated on the WRITE scope of what is being imported, computed per call —
|
|
6
|
+
# a customers import needs `write_customers`, a products import
|
|
7
|
+
# `write_products`. A single class-level key could not say that, which is
|
|
8
|
+
# why the resource reached the map read-only; `create_export` answers the
|
|
9
|
+
# same question the same way.
|
|
10
|
+
#
|
|
11
|
+
# Creating the import reads the file's header row and proposes a column
|
|
12
|
+
# mapping. Nothing is written to the catalog until that mapping is
|
|
13
|
+
# confirmed, so this is the safe half of the wizard: the merchant still
|
|
14
|
+
# sees what the columns were taken to mean before any row lands.
|
|
15
|
+
class CreateImport < Spree::AgentTool
|
|
16
|
+
tool_name 'create_import'
|
|
17
|
+
description 'Start a CSV import from a file the merchant provided. Call upload_file ' \
|
|
18
|
+
'first and pass the file_id it returns. The columns are read and a mapping ' \
|
|
19
|
+
'proposed; no records change until the mapping is confirmed.'
|
|
20
|
+
# Per-resource, answered in `call` — a class-level key would hide the
|
|
21
|
+
# tool from a caller who may import one kind of record but not another.
|
|
22
|
+
permission nil
|
|
23
|
+
mutating!
|
|
24
|
+
|
|
25
|
+
param :resource, description: 'What the file holds: products, customers, purchase_orders',
|
|
26
|
+
required: true
|
|
27
|
+
param :file_id, description: 'The file_id upload_file returned', required: true
|
|
28
|
+
|
|
29
|
+
def call(resource:, file_id:)
|
|
30
|
+
import_class = import_class_for(resource)
|
|
31
|
+
return unknown_resource(resource) if import_class.nil?
|
|
32
|
+
|
|
33
|
+
required = "write_#{import_class.required_scope}"
|
|
34
|
+
unless context.holds?(required)
|
|
35
|
+
return { error: "You do not have permission to import #{resource.to_s.tr('_', ' ')}." }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
start(import_class, resource, file_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def summary(arguments)
|
|
42
|
+
"Start a #{arguments[:resource].to_s.tr('_', ' ')} import"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def start(import_class, resource, file_id)
|
|
48
|
+
owner = importing_user
|
|
49
|
+
return { error: no_user_error } if owner.nil?
|
|
50
|
+
|
|
51
|
+
import = import_class.new(store: context.store, user: owner)
|
|
52
|
+
import.attachment = file_id
|
|
53
|
+
|
|
54
|
+
refusal = unauthorized(:create, import)
|
|
55
|
+
return refusal if refusal
|
|
56
|
+
return { error: import.errors.full_messages.to_sentence } unless import.save
|
|
57
|
+
|
|
58
|
+
mapping = Spree.import_start_mapping_workflow.call(import: import)
|
|
59
|
+
return { error: mapping_failure(mapping, import) } unless mapping.success?
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
ok: true,
|
|
63
|
+
id: import.prefixed_id,
|
|
64
|
+
resource: resource.to_s,
|
|
65
|
+
status: import.reload.status,
|
|
66
|
+
# The mapping is the point of this step: the model should read it
|
|
67
|
+
# back and tell the merchant what the columns were taken to mean
|
|
68
|
+
# before anything is written.
|
|
69
|
+
next_step: "Call describe_import_mapping with id #{import.prefixed_id} to see the " \
|
|
70
|
+
'columns, then confirm it to run the import.',
|
|
71
|
+
dashboard_path: ResourceMap.find('imports')&.dashboard_path_for(import)
|
|
72
|
+
}
|
|
73
|
+
rescue ::CSV::MalformedCSVError, EncodingError => e
|
|
74
|
+
{ error: "That file could not be read as CSV: #{e.message}" }
|
|
75
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
|
76
|
+
{ error: "#{file_id.inspect} is not a file this store uploaded. Call upload_file first." }
|
|
77
|
+
rescue ActiveStorage::FileNotFoundError
|
|
78
|
+
{ error: 'That upload did not finish — its bytes are missing. Upload the file again.' }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mapping_failure(result, import)
|
|
82
|
+
error = result.error
|
|
83
|
+
message = error.try(:value).is_a?(String) ? error.value : error.to_s
|
|
84
|
+
|
|
85
|
+
message.presence || import.processing_errors.presence || 'The file could not be mapped.'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# An import belongs to a person — it emails them when it finishes and
|
|
89
|
+
# its failed rows are theirs to retry. A grant carries the admin who
|
|
90
|
+
# approved it; a standing key carries whoever minted it, which is the
|
|
91
|
+
# same fallback the Admin API uses.
|
|
92
|
+
def importing_user
|
|
93
|
+
return context.user if context.user.present?
|
|
94
|
+
|
|
95
|
+
creator = context.api_key&.created_by
|
|
96
|
+
creator if creator.is_a?(Spree.admin_user_class)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def no_user_error
|
|
100
|
+
'An import has to belong to a staff member, and this credential names none. ' \
|
|
101
|
+
'Connect an agent through the dashboard, or start the import there.'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def import_class_for(resource)
|
|
105
|
+
key = resource.to_s.downcase
|
|
106
|
+
Spree::Import.available_types.find { |klass| klass.name.demodulize.underscore == key }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unknown_resource(resource)
|
|
110
|
+
{
|
|
111
|
+
error: "Cannot import #{resource.inspect}.",
|
|
112
|
+
available: Spree::Import.available_types.map { |k| k.name.demodulize.underscore }.sort
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Puts a file into the media library, optionally on a product.
|
|
4
|
+
#
|
|
5
|
+
# Creating media needs a file, which an agent had no way to supply until
|
|
6
|
+
# `upload_file` existed — which is why the resource reached the map
|
|
7
|
+
# read-only. The id that tool returns is what this one takes.
|
|
8
|
+
#
|
|
9
|
+
# A row with no owner is a library file: uploaded, not yet placed. Naming
|
|
10
|
+
# a product places it in that product's gallery, which is what a merchant
|
|
11
|
+
# handing over photographs usually means.
|
|
12
|
+
class CreateMedia < Spree::AgentTool
|
|
13
|
+
tool_name 'media_create'
|
|
14
|
+
description 'Add an uploaded file to the media library, or straight onto a product. ' \
|
|
15
|
+
'Call upload_file first and pass the file_id it returns. Without a product ' \
|
|
16
|
+
'the file sits in the library for the merchant to place later.'
|
|
17
|
+
permission 'write_media'
|
|
18
|
+
mutating!
|
|
19
|
+
|
|
20
|
+
param :file_id, description: 'The file_id upload_file returned', required: true
|
|
21
|
+
param :product_id, description: "A product's prefixed id, to place the file in its gallery"
|
|
22
|
+
param :alt, description: 'Alt text describing the image, for accessibility and SEO'
|
|
23
|
+
|
|
24
|
+
def call(file_id:, product_id: nil, alt: nil)
|
|
25
|
+
# Placement is the path: nested under a product puts the file in that
|
|
26
|
+
# product's gallery, the flat one leaves it in the library. The
|
|
27
|
+
# endpoint resolves the parent, checks it and verifies the signed id,
|
|
28
|
+
# so there is nothing to look up here first.
|
|
29
|
+
path = if product_id.present?
|
|
30
|
+
"/api/v3/admin/products/#{product_id}/media"
|
|
31
|
+
else
|
|
32
|
+
'/api/v3/admin/media'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
response = ApiDispatch.new(context).call(
|
|
36
|
+
method: :post, path: path, body: { signed_id: file_id, alt: alt.presence }.compact
|
|
37
|
+
)
|
|
38
|
+
unless response.success?
|
|
39
|
+
# The endpoint speaks about signed references in general, since many
|
|
40
|
+
# of its parameters are one. Here it is always a file, so the
|
|
41
|
+
# refusal names the tool that produces a valid id.
|
|
42
|
+
message = response.error_message
|
|
43
|
+
if response.status == 422 && message.to_s.match?(/signed reference/i)
|
|
44
|
+
message = "#{file_id.inspect} is not a file this store uploaded, or it has expired. " \
|
|
45
|
+
'Call upload_file first and pass the file_id it returns.'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return { error: message }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
media = response.data.to_h
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
ok: true,
|
|
55
|
+
id: media['id'],
|
|
56
|
+
filename: media['filename'],
|
|
57
|
+
media_type: media['media_type'],
|
|
58
|
+
product_id: product_id.presence,
|
|
59
|
+
# The media library has no page of its own in the map, so the link
|
|
60
|
+
# that helps is the product's — where the merchant will look at it.
|
|
61
|
+
dashboard_path: product_id.presence && dashboard_path_for(product_id)
|
|
62
|
+
}.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def summary(arguments)
|
|
66
|
+
return "Add a file to #{arguments[:product_id]}'s gallery" if arguments[:product_id].present?
|
|
67
|
+
|
|
68
|
+
'Add a file to the media library'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def dashboard_path_for(product_id)
|
|
74
|
+
path = ResourceMap.find('products')&.dashboard_path
|
|
75
|
+
return if path.blank?
|
|
76
|
+
|
|
77
|
+
format(path, id: product_id)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|