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.
Files changed (132) 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/addresses.rb +95 -32
  6. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  7. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  8. data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
  9. data/app/models/spree/ability.rb +8 -0
  10. data/app/models/spree/address.rb +6 -0
  11. data/app/models/spree/cart.rb +4 -6
  12. data/app/models/spree/company.rb +1 -1
  13. data/app/models/spree/data_request.rb +34 -1
  14. data/app/models/spree/delivery_rate.rb +12 -4
  15. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  16. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  17. data/app/models/spree/fulfillment.rb +1 -0
  18. data/app/models/spree/gateway/bogus.rb +7 -0
  19. data/app/models/spree/line_item.rb +3 -8
  20. data/app/models/spree/oauth_access_grant.rb +11 -0
  21. data/app/models/spree/oauth_access_token.rb +16 -0
  22. data/app/models/spree/oauth_application.rb +97 -0
  23. data/app/models/spree/oauth_consent_request.rb +23 -0
  24. data/app/models/spree/oauth_protected_resource.rb +21 -0
  25. data/app/models/spree/oauth_redirect.rb +12 -0
  26. data/app/models/spree/option_type.rb +5 -0
  27. data/app/models/spree/option_value.rb +5 -0
  28. data/app/models/spree/order.rb +6 -2
  29. data/app/models/spree/payment.rb +14 -2
  30. data/app/models/spree/payment_capture_event.rb +2 -0
  31. data/app/models/spree/policy.rb +6 -0
  32. data/app/models/spree/price.rb +16 -33
  33. data/app/models/spree/price_rule.rb +3 -16
  34. data/app/models/spree/price_rules/market_rule.rb +0 -6
  35. data/app/models/spree/pricing_provider/base.rb +1 -1
  36. data/app/models/spree/product.rb +1 -1
  37. data/app/models/spree/purchase_order_item.rb +1 -1
  38. data/app/models/spree/return.rb +1 -0
  39. data/app/models/spree/sales_context.rb +162 -0
  40. data/app/models/spree/search_provider/database.rb +1 -1
  41. data/app/models/spree/stock/estimator.rb +4 -20
  42. data/app/models/spree/store.rb +8 -0
  43. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  44. data/app/models/spree/tax_exemption.rb +26 -0
  45. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  46. data/app/models/spree/tax_exemption_reason.rb +62 -0
  47. data/app/models/spree/tax_line.rb +5 -4
  48. data/app/models/spree/tax_provider/base.rb +24 -4
  49. data/app/models/spree/tax_provider/internal.rb +36 -29
  50. data/app/models/spree/tax_rate.rb +9 -35
  51. data/app/models/spree/translations/batch.rb +14 -4
  52. data/app/services/spree/carts/destroy.rb +7 -2
  53. data/app/services/spree/carts/price_items.rb +2 -8
  54. data/app/services/spree/carts/split_by_seller.rb +6 -1
  55. data/app/services/spree/carts/update.rb +35 -11
  56. data/app/services/spree/data_requests/confirm.rb +37 -0
  57. data/app/services/spree/data_requests/create.rb +61 -13
  58. data/app/services/spree/email_templates/preview.rb +24 -3
  59. data/app/services/spree/seeds/store_resources.rb +1 -0
  60. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  61. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  62. data/app/services/spree/update_check.rb +8 -1
  63. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  64. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  65. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  66. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  67. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  68. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  69. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  70. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  71. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  72. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  73. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  74. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  75. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  76. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  77. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  78. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  79. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  80. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  81. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  82. data/app/workflows/spree/carts/complete.rb +115 -2
  83. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  84. data/app/workflows/spree/customers/anonymize.rb +38 -17
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/orders/cancel.rb +8 -0
  87. data/app/workflows/spree/orders/complete.rb +8 -0
  88. data/app/{services → workflows}/spree/orders/create.rb +50 -26
  89. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  90. data/app/workflows/spree/returns/refund.rb +4 -0
  91. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  92. data/app/workflows/spree/variants/create.rb +7 -4
  93. data/config/locales/en.yml +18 -0
  94. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  95. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  96. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  97. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  98. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  99. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  100. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  101. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  102. data/db/sample_data/orders.rb +1 -1
  103. data/lib/spree/agent_tool.rb +188 -0
  104. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  105. data/lib/spree/agent_tools/context.rb +191 -0
  106. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  107. data/lib/spree/agent_tools/membership_map.rb +61 -0
  108. data/lib/spree/agent_tools/record_summary.rb +219 -0
  109. data/lib/spree/agent_tools/registry.rb +131 -0
  110. data/lib/spree/agent_tools/resource_map.rb +352 -0
  111. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  112. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  113. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  114. data/lib/spree/agent_tools.rb +24 -0
  115. data/lib/spree/core/configuration.rb +1 -1
  116. data/lib/spree/core/controller_helpers/store.rb +3 -13
  117. data/lib/spree/core/dependencies.rb +9 -0
  118. data/lib/spree/core/engine.rb +37 -1
  119. data/lib/spree/core/model_extensions.rb +111 -0
  120. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  121. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  122. data/lib/spree/core/version.rb +1 -1
  123. data/lib/spree/core.rb +27 -2
  124. data/lib/spree/service_module.rb +11 -0
  125. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  126. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  127. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  128. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  129. data/lib/tasks/markets.rake +47 -0
  130. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  131. metadata +69 -7
  132. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -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
@@ -22,10 +22,11 @@ module Spree
22
22
  # core doesn't know about registers its reason here. The bar for a new
23
23
  # value: it distinguishes a treatment that reporting or invoicing has to
24
24
  # tell apart, not merely a different amount.
25
- class_attribute :taxability_reasons,
26
- default: %w[standard_rated reduced_rated zero_rated reverse_charge
27
- intra_community_supply export customer_exempt product_exempt
28
- not_collecting not_subject_to_tax]
25
+ BUILT_IN_TAXABILITY_REASONS = %w[standard_rated reduced_rated zero_rated reverse_charge
26
+ intra_community_supply export customer_exempt product_exempt
27
+ not_collecting not_subject_to_tax].freeze
28
+
29
+ class_attribute :taxability_reasons, default: BUILT_IN_TAXABILITY_REASONS.dup
29
30
 
30
31
  # Provider payloads (jurisdiction breakdowns, external ids) read nil-safe.
31
32
  attribute :data, default: -> { {} }
@@ -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
@@ -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 included_in_price name]
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.#{included_in_price? ? :including_tax : :excluding_tax}",
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 +write_<resource>+ scopes a caller needs to authorize this
49
- # batch — one per resource type present. Lets the API layer gate an
50
- # API-key request without re-deriving scope names.
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.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
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
@@ -52,9 +52,14 @@ module Spree
52
52
  success(cart: cart)
53
53
  end
54
54
 
55
+ # Lets go of everything the cart was only borrowing, so the cascade that
56
+ # follows reaches nothing but the cart's own rows. An entry in somebody's
57
+ # address book stays in it, and a row a fulfillment or a completed order
58
+ # still points at is detached rather than soft-deleted out from under
59
+ # them.
55
60
  def clear_addresses(cart:)
56
- cart.ship_address = nil unless cart.ship_address&.can_be_deleted?
57
- cart.bill_address = nil unless cart.bill_address&.can_be_deleted?
61
+ cart.ship_address = nil unless cart.owns_address?(cart.ship_address) && cart.ship_address.can_be_deleted?
62
+ cart.bill_address = nil unless cart.owns_address?(cart.bill_address) && cart.bill_address.can_be_deleted?
58
63
 
59
64
  success(cart: cart)
60
65
  end
@@ -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.price_including_vat_for(**vat_inputs)
79
+ amount = price.price
86
80
  # Marked before the blank guard: the provider has already answered,
87
- # so a restatement that comes back blank must still not send the
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.