spree_core 6.0.0.rc2 → 6.0.0.rc4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  6. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  7. data/app/models/spree/cart.rb +1 -0
  8. data/app/models/spree/delivery_rate.rb +12 -4
  9. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  10. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  11. data/app/models/spree/fulfillment.rb +1 -0
  12. data/app/models/spree/gateway/bogus.rb +7 -0
  13. data/app/models/spree/line_item.rb +3 -8
  14. data/app/models/spree/oauth_access_grant.rb +11 -0
  15. data/app/models/spree/oauth_access_token.rb +16 -0
  16. data/app/models/spree/oauth_application.rb +97 -0
  17. data/app/models/spree/oauth_consent_request.rb +23 -0
  18. data/app/models/spree/oauth_protected_resource.rb +21 -0
  19. data/app/models/spree/oauth_redirect.rb +12 -0
  20. data/app/models/spree/option_type.rb +5 -0
  21. data/app/models/spree/option_value.rb +5 -0
  22. data/app/models/spree/order.rb +5 -1
  23. data/app/models/spree/payment.rb +14 -2
  24. data/app/models/spree/payment_capture_event.rb +2 -0
  25. data/app/models/spree/policy.rb +6 -0
  26. data/app/models/spree/price.rb +16 -33
  27. data/app/models/spree/price_rule.rb +3 -16
  28. data/app/models/spree/price_rules/market_rule.rb +0 -6
  29. data/app/models/spree/pricing_provider/base.rb +1 -1
  30. data/app/models/spree/product.rb +1 -1
  31. data/app/models/spree/purchase_order_item.rb +1 -1
  32. data/app/models/spree/return.rb +1 -0
  33. data/app/models/spree/search_provider/database.rb +1 -1
  34. data/app/models/spree/stock/estimator.rb +4 -20
  35. data/app/models/spree/store.rb +8 -0
  36. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  37. data/app/models/spree/tax_exemption.rb +26 -0
  38. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  39. data/app/models/spree/tax_exemption_reason.rb +62 -0
  40. data/app/models/spree/tax_provider/base.rb +24 -4
  41. data/app/models/spree/tax_provider/internal.rb +36 -29
  42. data/app/models/spree/tax_rate.rb +9 -35
  43. data/app/models/spree/translations/batch.rb +14 -4
  44. data/app/services/spree/carts/price_items.rb +2 -8
  45. data/app/services/spree/carts/split_by_seller.rb +6 -1
  46. data/app/services/spree/carts/update.rb +34 -7
  47. data/app/services/spree/email_templates/preview.rb +24 -3
  48. data/app/services/spree/seeds/store_resources.rb +1 -0
  49. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  50. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  51. data/app/services/spree/update_check.rb +8 -1
  52. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  53. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  54. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  55. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  56. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  57. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  58. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  59. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  60. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  61. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  62. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  63. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  64. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  65. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  66. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  67. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  68. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  69. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  70. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  71. data/app/workflows/spree/carts/complete.rb +115 -2
  72. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  73. data/app/workflows/spree/orders/cancel.rb +8 -0
  74. data/app/workflows/spree/orders/complete.rb +8 -0
  75. data/app/{services → workflows}/spree/orders/create.rb +9 -9
  76. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  77. data/app/workflows/spree/returns/refund.rb +4 -0
  78. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  79. data/app/workflows/spree/variants/create.rb +7 -4
  80. data/config/locales/en.yml +14 -0
  81. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  82. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  83. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  84. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  85. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  86. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  87. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  88. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  89. data/db/sample_data/orders.rb +1 -1
  90. data/lib/spree/agent_tool.rb +188 -0
  91. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  92. data/lib/spree/agent_tools/context.rb +191 -0
  93. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  94. data/lib/spree/agent_tools/membership_map.rb +61 -0
  95. data/lib/spree/agent_tools/record_summary.rb +219 -0
  96. data/lib/spree/agent_tools/registry.rb +131 -0
  97. data/lib/spree/agent_tools/resource_map.rb +352 -0
  98. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  99. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  100. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  101. data/lib/spree/agent_tools.rb +24 -0
  102. data/lib/spree/core/configuration.rb +1 -1
  103. data/lib/spree/core/controller_helpers/store.rb +3 -13
  104. data/lib/spree/core/dependencies.rb +9 -0
  105. data/lib/spree/core/engine.rb +35 -1
  106. data/lib/spree/core/model_extensions.rb +111 -0
  107. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  108. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  109. data/lib/spree/core/version.rb +1 -1
  110. data/lib/spree/core.rb +27 -2
  111. data/lib/spree/service_module.rb +11 -0
  112. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  113. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  114. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  115. data/lib/tasks/markets.rake +47 -0
  116. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  117. metadata +67 -7
  118. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -9,7 +9,6 @@ module Spree
9
9
 
10
10
  has_prefix_id :price
11
11
 
12
- include Spree::VatPriceCalculation
13
12
  include Spree::StorePreferences
14
13
  include Spree::CanonicalDecimals
15
14
 
@@ -135,44 +134,28 @@ module Spree
135
134
  alias_attribute :price, :amount
136
135
  alias_attribute :compare_at_price, :compare_at_amount
137
136
 
138
- # Whether this figure is the merchant's answer for the buyer's geography
139
- # already. A price list narrowed by market or country states its prices for
140
- # that geography, so they are charged exactly as entered — restating one would
141
- # net it out with the *home* rate and destroy the price point the merchant
142
- # chose. Lists narrowed by anything else (a quantity, a group) were not set
143
- # for a country, so their prices restate like any other.
144
- #
145
- # @return [Boolean]
146
- def final_for_destination?
147
- rules = price_list&.price_rules.to_a
148
- return false unless rules.any?(&:geographic?)
149
-
150
- # Under 'any' the list can win on a non-geographic rule alone, so a
151
- # geographic rule being present does not mean this buyer's geography is why
152
- # the price applied. Under 'all' every rule matched, so it does.
153
- price_list.match_policy == 'all' || rules.all?(&:geographic?)
154
- end
155
-
156
- def price_including_vat_for(price_options)
157
- return price if final_for_destination?
158
-
159
- options = price_options.merge(tax_category: variant.tax_category)
160
- gross_amount(price, options)
137
+ # @deprecated Prices are charged as entered for every buyer since 6.0. Use {#price}; removed in 6.1.
138
+ def price_including_vat_for(_price_options = {})
139
+ Spree::Deprecation.warn('Spree::Price#price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #price instead.')
140
+ price
161
141
  end
162
142
 
163
- def compare_at_price_including_vat_for(price_options)
164
- return compare_at_price if final_for_destination?
165
-
166
- options = price_options.merge(tax_category: variant.tax_category)
167
- gross_amount(compare_at_price, options)
143
+ # @deprecated Use {#compare_at_price}; removed in 6.1.
144
+ def compare_at_price_including_vat_for(_price_options = {})
145
+ Spree::Deprecation.warn('Spree::Price#compare_at_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #compare_at_price instead.')
146
+ compare_at_price
168
147
  end
169
148
 
170
- def display_price_including_vat_for(price_options)
171
- Spree::Money.new(price_including_vat_for(price_options), currency: currency)
149
+ # @deprecated Use {#display_price}; removed in 6.1.
150
+ def display_price_including_vat_for(_price_options = {})
151
+ Spree::Deprecation.warn('Spree::Price#display_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #display_price instead.')
152
+ display_price
172
153
  end
173
154
 
174
- def display_compare_at_price_including_vat_for(price_options)
175
- Spree::Money.new(compare_at_price_including_vat_for(price_options), currency: currency)
155
+ # @deprecated Use {#display_compare_at_price}; removed in 6.1.
156
+ def display_compare_at_price_including_vat_for(_price_options = {})
157
+ Spree::Deprecation.warn('Spree::Price#display_compare_at_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #display_compare_at_price instead.')
158
+ display_compare_at_price
176
159
  end
177
160
 
178
161
  # returns the name of the price in a format of variant name and currency
@@ -16,18 +16,6 @@ module Spree
16
16
  raise NotImplementedError, "#{self.class.name} must implement #applicable?"
17
17
  end
18
18
 
19
- # Whether this rule narrows its price list by geography. A list carrying one
20
- # states the price for that geography outright, so its prices are charged as
21
- # entered and never restated for the buyer's VAT rate
22
- # (Spree::VatPriceCalculation). A rule that narrows by anything else — a
23
- # quantity, a group, a channel — leaves its prices open to restatement, since
24
- # they were not set for a country.
25
- #
26
- # @return [Boolean]
27
- def geographic?
28
- false
29
- end
30
-
31
19
  # Whether this rule narrows by something about the purchase rather than
32
20
  # about the buyer. A catalog-owned price list has already been matched on
33
21
  # audience by the time the resolver holds it — the catalog assignment is
@@ -45,10 +33,9 @@ module Spree
45
33
  false
46
34
  end
47
35
 
48
- # Instance-side reader, so rules are classified the same way
49
- # `geographic?` classifies them (`rules.any?(&:geographic?)`). A kind
50
- # whose answer depends on how it is configured overrides this, exactly
51
- # as MarketRule does for `geographic?`.
36
+ # Instance-side reader, so rules can be classified per row
37
+ # (`price_rules.select(&:contextual?)`). A kind whose answer depends on how it is
38
+ # configured overrides this.
52
39
  #
53
40
  # @return [Boolean]
54
41
  def contextual?
@@ -23,12 +23,6 @@ module Spree
23
23
  preferred_market_ids.map(&:to_s).include?(context.market.id.to_s)
24
24
  end
25
25
 
26
- # An empty market list matches every buyer (see #applicable?), so it names
27
- # no geography — and that is the state a freshly created rule row is in.
28
- def geographic?
29
- preferred_market_ids.present?
30
- end
31
-
32
26
  def self.description
33
27
  'Apply pricing based on the market'
34
28
  end
@@ -6,7 +6,7 @@ module Spree
6
6
  # A provider returns a {Spree::Price}: {Internal} returns the persisted
7
7
  # catalog row, an external provider an **unsaved, readonly** instance. The
8
8
  # Active Record shape is deliberately the contract, so everything
9
- # downstream — tax-inclusive restatement, +discounted?+, the serializers —
9
+ # downstream — the line item, +discounted?+, the serializers —
10
10
  # keeps working without knowing where the number came from. +readonly!+ is
11
11
  # what stops an external answer being written back into the catalog.
12
12
  #
@@ -887,7 +887,7 @@ module Spree
887
887
  next unless variant.should_track_inventory?
888
888
  next if variant.sku.blank? && variant != default_variant
889
889
 
890
- variant.stock_levels.each do |stock_level|
890
+ variant.stock_levels.sort_by(&:id).each do |stock_level|
891
891
  location = stock_level.stock_location
892
892
  next if location.nil? || location == default_stock_location
893
893
  next unless location.store_id == store.id && location_owner_ids.include?(location.seller_id)
@@ -11,7 +11,7 @@ module Spree
11
11
  belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', inverse_of: :items, touch: true
12
12
 
13
13
  validates :quantity_ordered, numericality: { greater_than: 0, only_integer: true }
14
- validates :unit_cost, numericality: { greater_than_or_equal_to: 0 }
14
+ validates :unit_cost, presence: true, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
15
15
  validates :variant_id, uniqueness: { scope: :purchase_order_id }
16
16
 
17
17
  delegate :currency, to: :purchase_order, allow_nil: true
@@ -19,6 +19,7 @@ module Spree
19
19
  include Spree::SingleStoreResource
20
20
  include Spree::HasStatus
21
21
  include Spree::HasCustomFields
22
+ include Spree::HasExternalReferences
22
23
  include Spree::Metadata
23
24
  include Spree::PostSale::Taxation
24
25
 
@@ -135,7 +135,7 @@ module Spree
135
135
  end
136
136
  }.join(',')
137
137
 
138
- scope.ransack({ s: ransack_sort }, auth_object: @audience).result
138
+ scope.ransack({ s: ransack_sort }, auth_object: @audience).result.with_translation_fallback_order
139
139
  end
140
140
  end
141
141
 
@@ -1,8 +1,6 @@
1
1
  module Spree
2
2
  module Stock
3
3
  class Estimator
4
- include Spree::VatPriceCalculation
5
-
6
4
  attr_reader :order, :currency
7
5
 
8
6
  def initialize(order)
@@ -69,7 +67,7 @@ module Spree
69
67
  # through the calculator, carrier providers call their API and may
70
68
  # return several estimates (one per carrier service). An empty result
71
69
  # suppresses the method, matching the calculator contract. Service
72
- # filtering, markup, naming, VAT gross-up, tax resolution and sorting
70
+ # filtering, markup, naming, tax resolution and sorting
73
71
  # stay here so every provider gets them identically.
74
72
  def calculate_delivery_rates(package, audience)
75
73
  delivery_methods(package, audience).flat_map do |delivery_method|
@@ -103,14 +101,12 @@ module Spree
103
101
  end
104
102
 
105
103
  # An unpriced quote has no amount to mark up or tax — a percentage of an
106
- # unknown price is still unknown, and rounding zero through the VAT
107
- # gross-up would only invent a number. Its cost stays zero and every
108
- # display surface reads +unpriced+ instead.
104
+ # unknown price is still unknown. Its cost stays zero and every display
105
+ # surface reads +unpriced+ instead.
109
106
  def rate_cost(estimate, delivery_method, service_row, provider)
110
107
  return 0 if estimate.unpriced
111
108
 
112
- cost = apply_markup(estimate.cost, delivery_method, service_row, provider)
113
- gross_amount(cost, taxation_options_for(delivery_method))
109
+ apply_markup(estimate.cost, delivery_method, service_row, provider)
114
110
  end
115
111
 
116
112
  # Handling fee on top of provider quotes: the service row's values when
@@ -132,18 +128,6 @@ module Spree
132
128
  [estimate.carrier, estimate.service_level].compact.join(' ').presence
133
129
  end
134
130
 
135
- # Override this if you need the prices for delivery methods to be handled just like the
136
- # prices for products in terms of included tax manipulation.
137
- #
138
- def taxation_options_for(delivery_method)
139
- {
140
- tax_category: delivery_method.tax_category,
141
- address: @order.tax_address,
142
- country: @order.tax_country,
143
- market: @order.market
144
- }
145
- end
146
-
147
131
  def first_tax_rate_for(tax_category)
148
132
  return unless @order.tax_address && tax_category
149
133
 
@@ -8,6 +8,12 @@ module Spree
8
8
 
9
9
  include FriendlyId
10
10
  include Spree::TranslatableResource
11
+
12
+ # The store itself is settings — see Admin::StoreController.
13
+ def self.translation_write_permission
14
+ 'write_settings'
15
+ end
16
+
11
17
  include Spree::HasCustomFields
12
18
  include Spree::Metadata
13
19
  include Spree::Stores::Setup
@@ -209,6 +215,7 @@ module Spree
209
215
  has_many :claim_reasons, class_name: 'Spree::ClaimReason', inverse_of: :store, dependent: :destroy
210
216
  has_many :refund_reasons, class_name: 'Spree::RefundReason', inverse_of: :store, dependent: :destroy
211
217
  has_many :order_cancellation_reasons, class_name: 'Spree::OrderCancellationReason', inverse_of: :store, dependent: :destroy
218
+ has_many :tax_exemption_reasons, class_name: 'Spree::TaxExemptionReason', inverse_of: :store, dependent: :destroy
212
219
 
213
220
  # :nullify (not :destroy) — clearing the collection must not cascade into
214
221
  # Promotion#not_used? / payment records; orphaned rows are detached, not deleted.
@@ -339,6 +346,7 @@ module Spree
339
346
  has_many :catalogs, class_name: 'Spree::Catalog', dependent: :destroy, inverse_of: :store
340
347
 
341
348
  has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :destroy
349
+ has_many :oauth_applications, class_name: 'Spree::OauthApplication', dependent: :destroy
342
350
  has_many :allowed_origins, class_name: 'Spree::AllowedOrigin', dependent: :destroy
343
351
 
344
352
  #
@@ -17,6 +17,20 @@ module Spree
17
17
  def exempt?
18
18
  exempt
19
19
  end
20
+
21
+ # @return [Hash]
22
+ def to_snapshot
23
+ attributes
24
+ end
25
+
26
+ # Unknown keys are dropped rather than raising: a snapshot is read back
27
+ # long after it was written, possibly by a different version of Spree.
28
+ #
29
+ # @param snapshot [Hash]
30
+ # @return [Spree::TaxExemption::ItemOverride]
31
+ def self.from_snapshot(snapshot)
32
+ new(snapshot.to_h.stringify_keys.slice(*attribute_names).symbolize_keys)
33
+ end
20
34
  end
21
35
  end
22
36
  end
@@ -14,6 +14,9 @@ module Spree
14
14
  # Why the sale is exempt, in the provider's vocabulary — Avalara's
15
15
  # entityUseCode territory.
16
16
  attribute :reason_code, :string
17
+ # The merchant's wording for the reason, kept with the claim so a placed
18
+ # order still reads the way it did when it was taxed.
19
+ attribute :reason_name, :string
17
20
  attribute :certificate_number, :string
18
21
 
19
22
  # Where the claim holds. Nil country = everywhere the provider taxes,
@@ -62,6 +65,29 @@ module Spree
62
65
  override_for(item)&.reason_code.presence || reason_code
63
66
  end
64
67
 
68
+ # The claim as plain data, for freezing onto a placed order. Certificates
69
+ # lapse, get revoked and are edited, so a provider filing or crediting that
70
+ # sale later has to read the evidence the sale was priced with rather than
71
+ # whatever resolves on the day.
72
+ #
73
+ # @return [Hash]
74
+ def to_snapshot
75
+ attributes.except('item_overrides').
76
+ merge('item_overrides' => Array(item_overrides).map(&:to_snapshot))
77
+ end
78
+
79
+ # Unknown keys are dropped rather than raising: a snapshot is read back long
80
+ # after it was written, possibly by a different version of Spree.
81
+ #
82
+ # @param snapshot [Hash]
83
+ # @return [Spree::TaxExemption]
84
+ def self.from_snapshot(snapshot)
85
+ snapshot = snapshot.to_h.stringify_keys
86
+ overrides = Array(snapshot['item_overrides']).map { |override| ItemOverride.from_snapshot(override) }
87
+
88
+ new(snapshot.slice(*attribute_names).symbolize_keys.merge(item_overrides: overrides))
89
+ end
90
+
65
91
  private
66
92
 
67
93
  # An override that cannot say which line it applies to does not narrow this
@@ -22,6 +22,7 @@ module Spree
22
22
  has_status :pending, :verified, :expired, :revoked, default: :pending
23
23
 
24
24
  belongs_to :company, class_name: 'Spree::Company', inverse_of: :tax_exemption_certificates
25
+ belongs_to :tax_exemption_reason, class_name: 'Spree::TaxExemptionReason', inverse_of: :tax_exemption_certificates
25
26
  belongs_to :verified_by, class_name: Spree.admin_user_class.to_s, optional: true
26
27
 
27
28
  # Confidential, so the private service rather than the public one that
@@ -29,12 +30,15 @@ module Spree
29
30
  has_one_attached :document, service: Spree.private_storage_service_name
30
31
  signed_id_attachments :document
31
32
 
32
- validates :certificate_number, :reason_code, presence: true
33
+ validates :certificate_number, presence: true
33
34
  # Certificates exist only on legal-entity nodes — a division's purchases
34
35
  # read exemption through its legal entity. A validation rather than a
35
36
  # schema difference, so a per-division registration can be allowed later
36
37
  # by relaxing this alone.
37
38
  validate :company_is_legal_entity
39
+ # Checked when the reason is set, not on every save: a reason the merchant
40
+ # retires later leaves the certificates that already record it valid.
41
+ validate :reason_offered_by_store, if: :will_save_change_to_tax_exemption_reason_id?
38
42
 
39
43
  # A certificate is a scanned form, not an archive. Bounded because the
40
44
  # download action reads the whole blob into memory to serve it, so an
@@ -64,7 +68,7 @@ module Spree
64
68
  where(state_code: [address.state_code.presence, nil].uniq)
65
69
  }
66
70
 
67
- self.whitelisted_ransackable_attributes = %w[certificate_number reason_code status expires_at]
71
+ self.whitelisted_ransackable_attributes = %w[certificate_number tax_exemption_reason_id status expires_at]
68
72
 
69
73
  delegate :store, :store_id, to: :company
70
74
 
@@ -94,5 +98,12 @@ module Spree
94
98
 
95
99
  errors.add(:company, :must_be_legal_entity)
96
100
  end
101
+
102
+ def reason_offered_by_store
103
+ return if tax_exemption_reason.nil? || company.nil?
104
+ return if tax_exemption_reason.active? && tax_exemption_reason.store_id == store_id
105
+
106
+ errors.add(:tax_exemption_reason, :not_offered)
107
+ end
97
108
  end
98
109
  end
@@ -0,0 +1,62 @@
1
+ module Spree
2
+ # Why a business buys without tax — "Resale", "Government", "Charitable
3
+ # organisation". Certificates point at a reason the way products point at a
4
+ # tax category: the `name` is the merchant's own wording, and the `code` is
5
+ # what a tax engine reads. Spree's own engine only keeps the code for
6
+ # reference, while an external one (Avalara's entity use codes) decides the
7
+ # exemption by it, so changing the code changes what every certificate
8
+ # holding the reason sends from then on.
9
+ class TaxExemptionReason < Spree.base_class
10
+ has_prefix_id :txr
11
+
12
+ include Spree::NamedType
13
+ include Spree::Metadata
14
+
15
+ # Guarded by hand rather than with :restrict_with_error, which would also
16
+ # refuse when the whole store is being destroyed.
17
+ has_many :tax_exemption_certificates, class_name: 'Spree::TaxExemptionCertificate',
18
+ inverse_of: :tax_exemption_reason, dependent: nil
19
+
20
+ self.whitelisted_ransackable_attributes = %w[name code active]
21
+
22
+ # Upper case because tax engines read codes case-insensitively: `g` and
23
+ # `G` would be two reasons here and one to Avalara.
24
+ normalizes :code, with: ->(value) { value.to_s.strip.upcase.presence }
25
+
26
+ validates :code, presence: true, length: { maximum: 64 }
27
+ validates_store_uniqueness :code
28
+
29
+ before_destroy :ensure_unused
30
+
31
+ # Finds the reason already holding the code, else the one holding the
32
+ # name, so seeding a store never collides with a reason the merchant added
33
+ # or renamed. Code first: it is what a tax engine reads, so a match on it
34
+ # is the same reason whatever the merchant called it.
35
+ #
36
+ # @param code [String]
37
+ # @param name [String]
38
+ # @return [Spree::TaxExemptionReason]
39
+ def self.find_or_create_by_code_or_name!(code:, name:)
40
+ code = normalize_value_for(:code, code)
41
+
42
+ find_by(code: code) || find_by(name: name) || create!(code: code, name: name)
43
+ end
44
+
45
+ # Mirrors the destroy guard, so the dashboard can hide the delete control
46
+ # rather than offer one the model will refuse.
47
+ #
48
+ # @return [Boolean]
49
+ def can_be_deleted?
50
+ !tax_exemption_certificates.exists?
51
+ end
52
+
53
+ private
54
+
55
+ def ensure_unused
56
+ return if destroyed_by_association.present? || can_be_deleted?
57
+
58
+ errors.add(:base, :in_use)
59
+ throw :abort
60
+ end
61
+ end
62
+ end
@@ -41,6 +41,16 @@ module Spree
41
41
  name.demodulize.titleize
42
42
  end
43
43
 
44
+ # The exemption reason codes this engine understands, `{ code => label }`,
45
+ # offered to a merchant adding a {Spree::TaxExemptionReason}. Nil for an
46
+ # engine that does not read the code, Spree's own included: any code a
47
+ # merchant chooses is then as good as another.
48
+ #
49
+ # @return [Hash{String => String}, nil]
50
+ def self.exemption_reason_codes
51
+ nil
52
+ end
53
+
44
54
  # The declared limits with the strings a merchant can act on. A capability
45
55
  # with no translation degrades to its humanized key rather than a missing
46
56
  # translation, so an extension that declares one without shipping strings
@@ -69,6 +79,7 @@ module Spree
69
79
  name: display_name,
70
80
  available: available_for_store?(store),
71
81
  unsupported_capabilities: unsupported_capability_details,
82
+ exemption_reason_codes: exemption_reason_codes&.map { |code, label| { value: code.to_s, label: label.to_s } },
72
83
  default: name == Spree.default_tax_provider.to_s
73
84
  }
74
85
  end
@@ -85,10 +96,18 @@ module Spree
85
96
  # @param tax_identifier [Spree::TaxIdentifier, nil] resolved buyer
86
97
  # registration, nil for a consumer sale
87
98
  # @param exemptions [Array] exemption evidence to apply
99
+ # @param tax_inclusive [Boolean, nil] whether the owner's prices already
100
+ # include tax, which its market decides (Spree::Market#tax_inclusive);
101
+ # nil = ask the owner (+owner.tax_inclusive?+)
88
102
  # @param context [Hash] untyped provider extras from set_tax_line_context
89
- # @return [void] the written rows are the output; raise when the
90
- # calculation cannot be completed
91
- def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], context: {})
103
+ # @return [void] the written rows are the output
104
+ # @raise [Spree::Tax::ProviderUnavailable] when the engine could not
105
+ # answer — refused, timed out, unreachable. Raise this (or a subclass,
106
+ # as a provider's own request error may) rather than a bare error: core
107
+ # turns it into a refusal the customer can retry, where anything else
108
+ # reaches them as a broken checkout. A provider a merchant switched off
109
+ # is not this — it has no opinion and writes no rows.
110
+ def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil, context: {})
92
111
  raise NotImplementedError, "Please implement 'estimate' in your tax provider: #{self.class.name}"
93
112
  end
94
113
 
@@ -171,9 +190,10 @@ module Spree
171
190
  # @param tax_date [Time, nil] date whose rates apply; nil = now
172
191
  # @param tax_identifier [Spree::TaxIdentifier, nil] the order's frozen registration
173
192
  # @param exemptions [Array] exemption evidence to apply
193
+ # @param tax_inclusive [Boolean, nil] as for +estimate+
174
194
  # @return [void]
175
195
  # @raise [Spree::Tax::ProviderError] when the tax could not be worked out
176
- def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [])
196
+ def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil)
177
197
  raise NotImplementedError, "Please implement 'estimate_replacement' in your tax provider: #{self.class.name}"
178
198
  end
179
199
 
@@ -1,12 +1,19 @@
1
1
  module Spree
2
2
  module TaxProvider
3
3
  # Computes tax directly from TaxRate configuration: rate × discounted
4
- # base, with VAT included-in-price math backed out of the gross basis.
4
+ # base, backed out of the price instead when the market's prices include
5
+ # tax.
5
6
  # Rates are matched on the owner's tax address, each naming its own country
6
7
  # and optionally a state. Fees without a tax category are taxed with the
7
8
  # default tax category's rates — except customs duties, which +taxable_items+
8
9
  # withholds (see Spree::Purchase::Taxation).
9
10
  class Internal < Base
11
+ # What a merchant sees when picking a market's tax engine: the class name
12
+ # says nothing to someone who never read the code.
13
+ def self.display_name
14
+ I18n.t('spree.tax_providers.internal')
15
+ end
16
+
10
17
  # Rates are configured per country or state with no local-tax breakdown, no buyer
11
18
  # registration handling, no distance-selling thresholds, and delivery taxed
12
19
  # at its own method's rate rather than apportioned across the rates of the
@@ -20,7 +27,8 @@ module Spree
20
27
  # period, so there is only ever one version of the configuration to read.
21
28
  # +tax_identifier+ likewise — reverse charge is on the unsupported list,
22
29
  # so a buyer registration changes nothing here.
23
- def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], context: {})
30
+ def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil, context: {})
31
+ tax_inclusive = owner.tax_inclusive? if tax_inclusive.nil?
24
32
  full_estimate = items.nil?
25
33
  items ||= owner.taxable_items
26
34
  # A duty this engine taxed before it was excluded — or through an
@@ -38,7 +46,7 @@ module Spree
38
46
  Spree::TaxLine.where(Spree::TaxLine.adjustable_key_for(klass) => group.map(&:id)).delete_all
39
47
  end
40
48
 
41
- items.each { |item| tax_item(owner, item, rates, exemptions) }
49
+ items.each { |item| tax_item(owner, item, rates, exemptions, tax_inclusive) }
42
50
  end
43
51
 
44
52
  # Gives back each credited unit's share of the TaxLines the sale actually
@@ -57,16 +65,17 @@ module Spree
57
65
  # An exemption is read as it applied to the line being replaced: a
58
66
  # certificate's per-line carve-out names that line, and the exchange line
59
67
  # replacing it would otherwise match no carve-out and come out exempt.
60
- def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [])
68
+ def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil)
61
69
  return if items.empty?
62
70
 
71
+ tax_inclusive = order.tax_inclusive? if tax_inclusive.nil?
63
72
  Spree::TaxLine.charges.where(exchange_line_item_id: items.map(&:id)).delete_all
64
73
 
65
74
  rates = rates_for(order)
66
75
  items.each do |item|
67
76
  next unless item.credited_quantity.to_i.positive?
68
77
 
69
- tax_item(order, item, rates, exemptions, exemption_subject: item.line_item || item)
78
+ tax_item(order, item, rates, exemptions, tax_inclusive, exemption_subject: item.line_item || item)
70
79
  end
71
80
  end
72
81
 
@@ -74,11 +83,8 @@ module Spree
74
83
  # else, matched on where the invoiced business sits and taxed under the
75
84
  # store's default category — this engine has no separate notion of a
76
85
  # services category, and a marketplace commission is standard-rated
77
- # nearly everywhere.
78
- #
79
- # Additional (not included-in-price) rates only: a commission is quoted
80
- # net and its VAT is added on top, so a rate configured as
81
- # tax-inclusive describes consumer pricing rather than this supply.
86
+ # nearly everywhere. A commission is quoted net and its tax added on top,
87
+ # whatever the store's markets do with their prices.
82
88
  #
83
89
  # Nil when the jurisdiction is unknown or configured with no matching
84
90
  # rate at all, so the caller falls back to its own default instead of
@@ -91,8 +97,7 @@ module Spree
91
97
 
92
98
  rates = Spree::TaxRate.for_store(store).
93
99
  for_jurisdiction(address.country_code, address.state_code).
94
- for_tax_category(category).
95
- where(included_in_price: false)
100
+ for_tax_category(category)
96
101
  return nil if rates.empty?
97
102
 
98
103
  rates.sum(:amount)
@@ -114,7 +119,7 @@ module Spree
114
119
  Spree::TaxRate.for_store(owner.store).for_jurisdiction(owner.tax_country&.iso, owner.tax_address&.state_code).to_a
115
120
  end
116
121
 
117
- def tax_item(owner, item, rates, exemptions, exemption_subject: item)
122
+ def tax_item(owner, item, rates, exemptions, tax_inclusive, exemption_subject: item)
118
123
  relevant_rates = rates.select { |rate| rate.tax_category_id == tax_category_id_for(item, owner) }
119
124
 
120
125
  # A matched rate always produces a row, zero-amount ones included:
@@ -129,15 +134,15 @@ module Spree
129
134
  # An exempt item has no tax to back out of its price, so its whole
130
135
  # basis is pre-tax.
131
136
  exempt_rates = taxed.select { |_rate, _jurisdiction, exemption| exemption }.map(&:first)
132
- store_pre_tax_amount(item, relevant_rates - exempt_rates)
137
+ store_pre_tax_amount(item, relevant_rates - exempt_rates, tax_inclusive)
133
138
 
134
139
  taxed.each do |rate, jurisdiction, exemption|
135
140
  if exemption
136
- write_tax_line(owner, item, rate, 0, 'customer_exempt', jurisdiction,
141
+ write_tax_line(owner, item, rate, 0, 'customer_exempt', jurisdiction, tax_inclusive,
137
142
  data: exemption_data(exemption, exemption_subject))
138
143
  else
139
- write_tax_line(owner, item, rate, compute_tax(rate, item, relevant_rates, owner.currency),
140
- reason_for(rate), jurisdiction)
144
+ write_tax_line(owner, item, rate, compute_tax(rate, item, relevant_rates, owner.currency, tax_inclusive),
145
+ reason_for(rate), jurisdiction, tax_inclusive)
141
146
  end
142
147
  end
143
148
  end
@@ -171,13 +176,11 @@ module Spree
171
176
 
172
177
  # Rounded per line, half up, to the order's currency — the internal
173
178
  # provider's choice; other providers round as their jurisdictions require.
174
- def compute_tax(rate, item, relevant_rates, currency)
179
+ def compute_tax(rate, item, relevant_rates, currency, tax_inclusive)
175
180
  basis = taxable_basis(item)
176
181
 
177
- tax = if rate.included_in_price
178
- included_sum = relevant_rates.select(&:included_in_price).sum(&:amount)
179
- included_sum = rate.amount if included_sum.zero?
180
- basis / (1 + included_sum) * rate.amount
182
+ tax = if tax_inclusive
183
+ basis / (1 + relevant_rates.sum(&:amount)) * rate.amount
181
184
  else
182
185
  basis * rate.amount
183
186
  end
@@ -186,12 +189,11 @@ module Spree
186
189
 
187
190
  # Pre-tax amounts are stored so refund and reporting code can weigh
188
191
  # discounted, tax-exclusive values later (see #4318).
189
- def store_pre_tax_amount(item, rates)
192
+ def store_pre_tax_amount(item, rates, tax_inclusive)
190
193
  return unless item.respond_to?(:pre_tax_amount)
191
194
 
192
195
  pre_tax_amount = taxable_basis(item)
193
- included_rates = rates.select(&:included_in_price)
194
- pre_tax_amount /= (1 + included_rates.sum(&:amount)) if included_rates.any?
196
+ pre_tax_amount /= (1 + rates.sum(&:amount)) if tax_inclusive && rates.any?
195
197
 
196
198
  item.update_column(:pre_tax_amount, pre_tax_amount)
197
199
  end
@@ -226,15 +228,20 @@ module Spree
226
228
  # a genuinely exempt supply depends on which exemption applied, which the
227
229
  # reason alone cannot say.
228
230
  def exemption_data(exemption, item)
231
+ reason_code = exemption.reason_code_for(item)
232
+
229
233
  {
230
234
  'exemption' => {
231
- 'reason_code' => exemption.reason_code_for(item),
235
+ 'reason_code' => reason_code,
236
+ # A per-line override names only a code, so the entry's name would
237
+ # describe a different reason.
238
+ 'reason_name' => (exemption.reason_name if reason_code == exemption.reason_code),
232
239
  'certificate_number' => exemption.certificate_number
233
240
  }.compact
234
241
  }
235
242
  end
236
243
 
237
- def write_tax_line(owner, item, rate, amount, reason, jurisdiction, data: {})
244
+ def write_tax_line(owner, item, rate, amount, reason, jurisdiction, tax_inclusive, data: {})
238
245
  owner_key = owner.is_a?(Spree::Order) ? :order : :cart
239
246
  Spree::TaxLine.create!(
240
247
  {
@@ -243,8 +250,8 @@ module Spree
243
250
  tax_rate: rate,
244
251
  amount: amount,
245
252
  rate: rate.amount,
246
- label: rate.adjustment_label,
247
- included: rate.included_in_price,
253
+ label: rate.adjustment_label(included: tax_inclusive),
254
+ included: tax_inclusive,
248
255
  provider_id: 'internal',
249
256
  taxability_reason: reason,
250
257
  data: data