spree_core 6.0.0.rc3 → 6.0.0.rc5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/mailers/spree/email_template_mailer.rb +5 -2
- data/app/models/concerns/spree/has_external_references.rb +7 -6
- data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
- data/app/models/concerns/spree/purchase/addresses.rb +95 -32
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
- data/app/models/spree/ability.rb +8 -0
- data/app/models/spree/address.rb +6 -0
- data/app/models/spree/cart.rb +4 -6
- data/app/models/spree/company.rb +1 -1
- data/app/models/spree/data_request.rb +34 -1
- data/app/models/spree/delivery_rate.rb +12 -4
- data/app/models/spree/delivery_rate_provider/base.rb +1 -1
- data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
- data/app/models/spree/fulfillment.rb +1 -0
- data/app/models/spree/gateway/bogus.rb +7 -0
- data/app/models/spree/line_item.rb +3 -8
- data/app/models/spree/oauth_access_grant.rb +11 -0
- data/app/models/spree/oauth_access_token.rb +16 -0
- data/app/models/spree/oauth_application.rb +97 -0
- data/app/models/spree/oauth_consent_request.rb +23 -0
- data/app/models/spree/oauth_protected_resource.rb +21 -0
- data/app/models/spree/oauth_redirect.rb +12 -0
- data/app/models/spree/option_type.rb +5 -0
- data/app/models/spree/option_value.rb +5 -0
- data/app/models/spree/order.rb +6 -2
- data/app/models/spree/payment.rb +14 -2
- data/app/models/spree/payment_capture_event.rb +2 -0
- data/app/models/spree/policy.rb +6 -0
- data/app/models/spree/price.rb +16 -33
- data/app/models/spree/price_rule.rb +3 -16
- data/app/models/spree/price_rules/market_rule.rb +0 -6
- data/app/models/spree/pricing_provider/base.rb +1 -1
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/purchase_order_item.rb +1 -1
- data/app/models/spree/return.rb +1 -0
- data/app/models/spree/sales_context.rb +162 -0
- data/app/models/spree/search_provider/database.rb +1 -1
- data/app/models/spree/stock/estimator.rb +4 -20
- data/app/models/spree/store.rb +8 -0
- data/app/models/spree/tax_exemption/item_override.rb +14 -0
- data/app/models/spree/tax_exemption.rb +26 -0
- data/app/models/spree/tax_exemption_certificate.rb +13 -2
- data/app/models/spree/tax_exemption_reason.rb +62 -0
- data/app/models/spree/tax_line.rb +5 -4
- data/app/models/spree/tax_provider/base.rb +24 -4
- data/app/models/spree/tax_provider/internal.rb +36 -29
- data/app/models/spree/tax_rate.rb +9 -35
- data/app/models/spree/translations/batch.rb +14 -4
- data/app/services/spree/carts/destroy.rb +7 -2
- data/app/services/spree/carts/price_items.rb +2 -8
- data/app/services/spree/carts/split_by_seller.rb +6 -1
- data/app/services/spree/carts/update.rb +35 -11
- data/app/services/spree/data_requests/confirm.rb +37 -0
- data/app/services/spree/data_requests/create.rb +61 -13
- data/app/services/spree/email_templates/preview.rb +24 -3
- data/app/services/spree/seeds/store_resources.rb +1 -0
- data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
- data/app/services/spree/tax/resolve_exemptions.rb +11 -2
- data/app/services/spree/update_check.rb +8 -1
- data/app/tools/spree/agent_tools/create_export.rb +116 -0
- data/app/tools/spree/agent_tools/create_import.rb +117 -0
- data/app/tools/spree/agent_tools/create_media.rb +81 -0
- data/app/tools/spree/agent_tools/create_resource.rb +36 -0
- data/app/tools/spree/agent_tools/curate_products.rb +90 -0
- data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
- data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
- data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
- data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
- data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
- data/app/tools/spree/agent_tools/get_resource.rb +97 -0
- data/app/tools/spree/agent_tools/import_tool.rb +80 -0
- data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
- data/app/tools/spree/agent_tools/query_report.rb +124 -0
- data/app/tools/spree/agent_tools/resource_write.rb +99 -0
- data/app/tools/spree/agent_tools/search_resources.rb +155 -0
- data/app/tools/spree/agent_tools/set_stock.rb +52 -0
- data/app/tools/spree/agent_tools/update_resource.rb +42 -0
- data/app/tools/spree/agent_tools/upload_file.rb +104 -0
- data/app/workflows/spree/carts/complete.rb +115 -2
- data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
- data/app/workflows/spree/customers/anonymize.rb +38 -17
- data/app/workflows/spree/data_requests/fulfill.rb +20 -6
- data/app/workflows/spree/orders/cancel.rb +8 -0
- data/app/workflows/spree/orders/complete.rb +8 -0
- data/app/{services → workflows}/spree/orders/create.rb +50 -26
- data/app/workflows/spree/purchase_orders/create.rb +3 -0
- data/app/workflows/spree/returns/refund.rb +4 -0
- data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
- data/app/workflows/spree/variants/create.rb +7 -4
- data/config/locales/en.yml +18 -0
- data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
- data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
- data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
- data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
- data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
- data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
- data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/spree/agent_tool.rb +188 -0
- data/lib/spree/agent_tools/api_dispatch.rb +204 -0
- data/lib/spree/agent_tools/context.rb +191 -0
- data/lib/spree/agent_tools/default_catalog.rb +180 -0
- data/lib/spree/agent_tools/membership_map.rb +61 -0
- data/lib/spree/agent_tools/record_summary.rb +219 -0
- data/lib/spree/agent_tools/registry.rb +131 -0
- data/lib/spree/agent_tools/resource_map.rb +352 -0
- data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
- data/lib/spree/agent_tools/workflow_schema.rb +279 -0
- data/lib/spree/agent_tools/workflow_tool.rb +654 -0
- data/lib/spree/agent_tools.rb +24 -0
- data/lib/spree/core/configuration.rb +1 -1
- data/lib/spree/core/controller_helpers/store.rb +3 -13
- data/lib/spree/core/dependencies.rb +9 -0
- data/lib/spree/core/engine.rb +37 -1
- data/lib/spree/core/model_extensions.rb +111 -0
- data/lib/spree/core/permissions/default_catalog.rb +6 -1
- data/lib/spree/core/pricing/price_resolution.rb +4 -4
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -2
- data/lib/spree/service_module.rb +11 -0
- data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
- data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
- data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
- data/lib/tasks/markets.rake +47 -0
- data/lib/tasks/tax_exemption_reasons.rake +48 -0
- metadata +69 -7
- data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
|
@@ -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
|
|
@@ -205,10 +206,7 @@ module Spree
|
|
|
205
206
|
end
|
|
206
207
|
|
|
207
208
|
def resolve_address_id(prefixed_id)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
decoded = Spree::Address.decode_prefixed_id(prefixed_id)
|
|
211
|
-
decoded ? cart.customer.addresses.find_by(id: decoded)&.id : nil
|
|
209
|
+
cart.selectable_address(prefixed_id)&.id
|
|
212
210
|
end
|
|
213
211
|
|
|
214
212
|
def assign_market
|
|
@@ -229,6 +227,29 @@ module Spree
|
|
|
229
227
|
end
|
|
230
228
|
end
|
|
231
229
|
|
|
230
|
+
# Three ways an external service can leave a sale unpriced, and a
|
|
231
|
+
# customer can only act on what they are told. A refused calculation
|
|
232
|
+
# carries the engine's own words, because "the postcode is not valid for
|
|
233
|
+
# that state" is something they can fix and "please try again" is not.
|
|
234
|
+
# The same split the API's own handler makes: a refusal names something
|
|
235
|
+
# to fix, an outage is worth retrying, and a price that could not be
|
|
236
|
+
# confirmed is neither tax case.
|
|
237
|
+
def unavailable_code_for(error)
|
|
238
|
+
case error
|
|
239
|
+
when Spree::Tax::CalculationRefused then 'tax_calculation_refused'
|
|
240
|
+
when Spree::Tax::ProviderError then 'tax_provider_unavailable'
|
|
241
|
+
else 'provider_unavailable'
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def unavailable_message_for(error)
|
|
246
|
+
case error
|
|
247
|
+
when Spree::Tax::CalculationRefused then error.message.to_s
|
|
248
|
+
when Spree::Tax::ProviderError then I18n.t('spree.cart_line_item.tax_unavailable')
|
|
249
|
+
else I18n.t('spree.cart_line_item.pricing_unavailable')
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
232
253
|
# Three-way dispatch on the cart→checkout transition:
|
|
233
254
|
# entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
|
|
234
255
|
# A failed Reserve raises so the enclosing transaction rolls back and the
|
|
@@ -256,10 +277,13 @@ module Spree
|
|
|
256
277
|
else
|
|
257
278
|
cart.recalculate_totals!
|
|
258
279
|
end
|
|
259
|
-
rescue Spree::Pricing::PriceResolution::ProviderUnavailable
|
|
280
|
+
rescue Spree::Pricing::PriceResolution::ProviderUnavailable, Spree::Tax::ProviderError
|
|
260
281
|
# Deliberately not swallowed like the rest: a strict store refusing to
|
|
261
282
|
# price must roll the whole update back, so `call` sees it and the
|
|
262
|
-
# address change does not commit around unconfirmed prices.
|
|
283
|
+
# address change does not commit around unconfirmed prices. A tax engine
|
|
284
|
+
# that cannot answer is the same problem one column over — swallowing it
|
|
285
|
+
# showed the customer a total with tax missing and saved the address
|
|
286
|
+
# anyway, so the mistake only surfaced when they tried to pay.
|
|
263
287
|
raise
|
|
264
288
|
rescue StandardError => e
|
|
265
289
|
Rails.error.report(e, context: { order_id: cart.id }, source: 'spree.checkout')
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module DataRequests
|
|
3
|
+
# Confirms a request the customer raised, from the link emailed to the
|
|
4
|
+
# address it names, and queues the work.
|
|
5
|
+
#
|
|
6
|
+
# Confirming again is answered with the request as it stands, so a second
|
|
7
|
+
# click on the same link changes nothing.
|
|
8
|
+
class Confirm
|
|
9
|
+
prepend Spree::ServiceModule::Base
|
|
10
|
+
|
|
11
|
+
# @param data_request [Spree::DataRequest]
|
|
12
|
+
# @return [Spree::ServiceModule::Result] the confirmed request
|
|
13
|
+
def call(data_request:)
|
|
14
|
+
newly_confirmed = false
|
|
15
|
+
|
|
16
|
+
data_request.with_lock do
|
|
17
|
+
newly_confirmed = data_request.confirmable?
|
|
18
|
+
|
|
19
|
+
if newly_confirmed
|
|
20
|
+
data_request.update!(status: 'pending')
|
|
21
|
+
elsif data_request.awaiting_confirmation?
|
|
22
|
+
data_request.update!(status: 'lapsed')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if data_request.lapsed?
|
|
27
|
+
data_request.errors.add(:base, I18n.t('spree.data_request_errors.confirmation_invalid'))
|
|
28
|
+
return failure(data_request)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if newly_confirmed
|
|
32
|
+
|
|
33
|
+
success(data_request)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -7,9 +7,15 @@ module Spree
|
|
|
7
7
|
# whole history, and a customer holding the button down should not be able
|
|
8
8
|
# to queue a hundred of them. It is also the honest answer — the request
|
|
9
9
|
# they already made is still being worked on.
|
|
10
|
+
#
|
|
11
|
+
# A request the customer raised themselves waits for confirmation from its
|
|
12
|
+
# email address instead of being queued, because it reaches guest orders
|
|
13
|
+
# placed at that address. Asking again while it waits sends the email again.
|
|
10
14
|
class Create
|
|
11
15
|
prepend Spree::ServiceModule::Base
|
|
12
16
|
|
|
17
|
+
CONFIRMATION_EVENT = 'data_request.confirmation_requested'.freeze
|
|
18
|
+
|
|
13
19
|
# @param store [Spree::Store]
|
|
14
20
|
# @param customer [Spree::Customer]
|
|
15
21
|
# @param kind [String] Spree::DataRequest::ACCESS or ERASURE
|
|
@@ -19,8 +25,12 @@ module Spree
|
|
|
19
25
|
# caller fulfils the request itself — the admin export renders the file
|
|
20
26
|
# inline for a waiting staffer, and queueing would additionally email
|
|
21
27
|
# the customer a copy of something they did not ask for
|
|
28
|
+
# @param confirm_by_email [Boolean] hold the request until it is
|
|
29
|
+
# confirmed from its email address; true for the customer's own requests
|
|
30
|
+
# @param redirect_url [String, nil] the storefront page the confirmation
|
|
31
|
+
# link opens, already checked against the store's allowed origins
|
|
22
32
|
# @return [Spree::ServiceModule::Result]
|
|
23
|
-
def call(store:, customer:, kind:, requested_by: nil, process: true)
|
|
33
|
+
def call(store:, customer:, kind:, requested_by: nil, process: true, confirm_by_email: false, redirect_url: nil)
|
|
24
34
|
# Nothing useful is left to answer with: an export would return the
|
|
25
35
|
# tombstone and email a link to an address that no longer receives,
|
|
26
36
|
# and a second erasure has nothing to erase.
|
|
@@ -38,7 +48,9 @@ module Spree
|
|
|
38
48
|
# of the same person's whole history; taking the lock closes it without
|
|
39
49
|
# a partial unique index, which MySQL does not support.
|
|
40
50
|
customer.with_lock do
|
|
41
|
-
|
|
51
|
+
lapse_unconfirmed_requests(store: store, customer: customer, kind: kind, staff_request: !confirm_by_email)
|
|
52
|
+
existing = in_flight_request(store: store, customer: customer, kind: kind,
|
|
53
|
+
include_unconfirmed: confirm_by_email)
|
|
42
54
|
|
|
43
55
|
unless existing
|
|
44
56
|
data_request = Spree::DataRequest.new(
|
|
@@ -46,20 +58,27 @@ module Spree
|
|
|
46
58
|
customer: customer,
|
|
47
59
|
kind: kind.to_s,
|
|
48
60
|
email: customer.email,
|
|
49
|
-
requested_by: requested_by
|
|
61
|
+
requested_by: requested_by,
|
|
62
|
+
status: confirm_by_email ? 'awaiting_confirmation' : 'pending'
|
|
50
63
|
)
|
|
51
64
|
data_request.save
|
|
52
65
|
end
|
|
53
66
|
end
|
|
54
67
|
|
|
55
|
-
return
|
|
56
|
-
return failure(data_request) unless data_request.persisted?
|
|
68
|
+
return failure(data_request) if data_request && !data_request.persisted?
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
# and it should not contend for the row this flow still holds.
|
|
60
|
-
Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if process
|
|
70
|
+
request = existing || data_request
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
# Both happen after the lock is released: the job may start at once and
|
|
73
|
+
# should not contend for the row this flow holds, and the email must
|
|
74
|
+
# not go out before the row is committed.
|
|
75
|
+
if request.awaiting_confirmation?
|
|
76
|
+
request_confirmation(request, redirect_url: redirect_url)
|
|
77
|
+
elsif data_request && process
|
|
78
|
+
Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
success(request)
|
|
63
82
|
end
|
|
64
83
|
|
|
65
84
|
private
|
|
@@ -67,10 +86,39 @@ module Spree
|
|
|
67
86
|
# Only the person's own requests. A staff member exporting from the admin
|
|
68
87
|
# opens their own row and answers it inline, and handing that back here
|
|
69
88
|
# would close the customer's request with a file they can never download.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
# A request still waiting for confirmation is returned only to the
|
|
90
|
+
# customer: staff have checked who they are dealing with, so theirs
|
|
91
|
+
# proceeds on its own row (see lapse_unconfirmed_requests).
|
|
92
|
+
def in_flight_request(store:, customer:, kind:, include_unconfirmed:)
|
|
93
|
+
requests = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
|
|
94
|
+
requested_by_id: nil)
|
|
95
|
+
open_requests = include_unconfirmed ? requests.in_progress.or(requests.confirmable) : requests.in_progress
|
|
96
|
+
|
|
97
|
+
open_requests.recent_first.first
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Lapses what can no longer be confirmed: requests past the window, and
|
|
101
|
+
# requests naming an address the account has since moved away from, so
|
|
102
|
+
# asking again emails the current address. A staff request of the same
|
|
103
|
+
# kind replaces the customer's unconfirmed one, which would otherwise
|
|
104
|
+
# run the same work a second time once confirmed.
|
|
105
|
+
def lapse_unconfirmed_requests(store:, customer:, kind:, staff_request:)
|
|
106
|
+
unconfirmed = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id).awaiting_confirmation
|
|
107
|
+
stale = unconfirmed.past_confirmation_window.or(unconfirmed.where.not(email: customer.email))
|
|
108
|
+
stale = stale.or(unconfirmed.where(kind: kind.to_s)) if staff_request
|
|
109
|
+
|
|
110
|
+
stale.find_each { |data_request| data_request.update!(status: 'lapsed') }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The token travels in a credential event, which reaches only webhook
|
|
114
|
+
# endpoints that name it and keeps the token out of the delivery log.
|
|
115
|
+
def request_confirmation(data_request, redirect_url:)
|
|
116
|
+
payload = data_request.event_payload_for(
|
|
117
|
+
CONFIRMATION_EVENT,
|
|
118
|
+
confirmation_token: data_request.generate_token_for(:confirmation),
|
|
119
|
+
redirect_url: redirect_url.presence
|
|
120
|
+
)
|
|
121
|
+
data_request.publish_event(CONFIRMATION_EVENT, payload)
|
|
74
122
|
end
|
|
75
123
|
end
|
|
76
124
|
end
|
|
@@ -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
|