solidus_advanced_pricing 0.1.0 → 0.3.0

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +116 -1
  4. data/README.md +243 -16
  5. data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +33 -1
  6. data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +3 -1
  7. data/app/models/solidus_advanced_pricing/price_batch.rb +264 -0
  8. data/app/models/solidus_advanced_pricing/price_selector.rb +3 -1
  9. data/app/models/solidus_advanced_pricing/price_type.rb +2 -0
  10. data/app/models/solidus_advanced_pricing/price_type_cache.rb +19 -1
  11. data/config/locales/en.yml +11 -2
  12. data/config/routes.rb +8 -1
  13. data/db/migrate/20260923000001_add_role_to_solidus_advanced_pricing_price_types.rb +10 -0
  14. data/lib/components/admin/solidus_admin/price_types/index/component.rb +4 -0
  15. data/lib/controllers/admin/solidus_admin/price_types_controller.rb +1 -1
  16. data/lib/controllers/api/spree/api/price_batches_controller.rb +47 -0
  17. data/lib/controllers/api/spree/api/price_types_controller.rb +17 -0
  18. data/lib/controllers/api/spree/api/prices_controller.rb +79 -1
  19. data/lib/controllers/backend/spree/admin/price_types_controller.rb +1 -1
  20. data/lib/solidus_advanced_pricing/configuration.rb +14 -3
  21. data/lib/solidus_advanced_pricing/version.rb +1 -1
  22. data/lib/views/api/spree/api/price_batches/create.json.jbuilder +6 -0
  23. data/lib/views/api/spree/api/price_types/_price_type.json.jbuilder +1 -0
  24. data/lib/views/api/spree/api/price_types/index.json.jbuilder +3 -0
  25. data/lib/views/backend/spree/admin/price_types/_form.html.erb +11 -0
  26. data/lib/views/backend/spree/admin/price_types/index.html.erb +2 -0
  27. data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +5 -4
  28. data/solidus_advanced_pricing.gemspec +3 -3
  29. metadata +12 -6
  30. data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +0 -351
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusAdvancedPricing
4
+ # Applies many price rows in one call, keyed on the dimensions a price
5
+ # legitimately varies on, so re-sending the same payload is a no-op rather
6
+ # than a pile of duplicates.
7
+ #
8
+ # Deliberately generic: it validates and writes prices and reports what it
9
+ # did. Store policy -- how large a swing is allowed, what gets reindexed,
10
+ # what gets published on an event bus -- belongs to the host app, which can
11
+ # hang it off `SolidusAdvancedPricing.config.batch_guard` or act on the
12
+ # returned results.
13
+ class PriceBatch
14
+ MODES = %w[upsert replace].freeze
15
+
16
+ # Two rows agreeing on all of these are the same price. `valid_to` is
17
+ # deliberately absent: extending or shortening a window is an edit to an
18
+ # existing price, not a different one.
19
+ NATURAL_KEY = %i[variant_id currency country_iso price_type_id role_id valid_from].freeze
20
+
21
+ # Everything a natural-key row may change. Anything in the key is, by
22
+ # definition, how the row was found, so changing it there is incoherent.
23
+ UPDATABLE = %i[amount valid_to admin_notes].freeze
24
+
25
+ # An `id` names the row outright, so nothing is off-limits except which
26
+ # variant it hangs off. `price_type_id` is listed but the model refuses to
27
+ # change it on a persisted price, which surfaces as an ordinary row error.
28
+ UPDATABLE_BY_ID = %i[amount currency country_iso price_type_id role_id valid_from valid_to admin_notes].freeze
29
+
30
+ class TooManyRows < StandardError; end
31
+
32
+ class InvalidMode < StandardError; end
33
+
34
+ Result = Struct.new(:index, :status, :price_id, :variant_id, :errors, keyword_init: true) do
35
+ def to_h
36
+ {index: index, status: status, price_id: price_id, variant_id: variant_id, errors: errors}.compact
37
+ end
38
+ end
39
+
40
+ attr_reader :results, :mode
41
+
42
+ def initialize(rows:, mode: "upsert", dry_run: false, row_limit: nil)
43
+ @rows = Array(rows)
44
+ @mode = mode.to_s
45
+ @dry_run = ActiveModel::Type::Boolean.new.cast(dry_run) || false
46
+ @row_limit = row_limit || SolidusAdvancedPricing.config.batch_row_limit
47
+ @results = []
48
+ @touched_ids = Hash.new { |hash, key| hash[key] = [] }
49
+
50
+ raise InvalidMode, "mode must be one of #{MODES.join(", ")}" unless MODES.include?(@mode)
51
+ raise TooManyRows, "batch is limited to #{@row_limit} rows, got #{@rows.size}" if @rows.size > @row_limit
52
+ end
53
+
54
+ def dry_run? = @dry_run
55
+
56
+ def replace? = @mode == "replace"
57
+
58
+ def call
59
+ # A dry run does the real work against the database and throws it away, so
60
+ # what it reports is what the write would actually do -- validations, type
61
+ # casting and constraints included -- rather than a guess at it.
62
+ ActiveRecord::Base.transaction do
63
+ seen = {}
64
+
65
+ @rows.each_with_index do |row, index|
66
+ apply_row(row.to_h.symbolize_keys, index, seen)
67
+ end
68
+
69
+ apply_replacements if replace?
70
+
71
+ # The host app's veto, with every row already written and nothing
72
+ # committed. Raising from here aborts the whole batch -- which is the
73
+ # only place a "this moves too many prices too far" rule can see the
74
+ # finished picture and still stop it.
75
+ SolidusAdvancedPricing.config.batch_guard&.call(self)
76
+
77
+ raise ActiveRecord::Rollback if dry_run?
78
+ end
79
+
80
+ self
81
+ end
82
+
83
+ def summary
84
+ results.group_by(&:status).transform_values(&:size)
85
+ end
86
+
87
+ private
88
+
89
+ # Resolution happens outside the savepoint -- it touches no state, and a
90
+ # `return` out of a transaction block is a trap worth not setting.
91
+ # Two ways to name a row, and `id` wins when it is there: it says exactly
92
+ # which price to change, with no key to assemble and no precision to lose.
93
+ #
94
+ # The natural key is what a payload authored somewhere that does not know
95
+ # Solidus ids -- a supplier feed, a merchandiser's spreadsheet, a backfill
96
+ # from another system -- has instead. It is also what makes such a payload
97
+ # re-runnable: without it, a nightly feed has no ids on its rows and would
98
+ # create a fresh duplicate of every price on every pass.
99
+ def apply_row(row, index, seen)
100
+ located = row[:id].present? ? locate_by_id(row) : locate_by_key(row)
101
+ return record(index, :error, errors: [located]) if located.is_a?(String)
102
+
103
+ price, variant_id, price_type_id, dedup, attributes = located
104
+
105
+ if seen.key?(dedup)
106
+ return record(index, :error, variant_id: variant_id,
107
+ errors: ["duplicate of row #{seen[dedup]}: #{duplicate_reason(dedup)}"])
108
+ end
109
+ seen[dedup] = index
110
+
111
+ persist(index, price, variant_id, price_type_id, attributes)
112
+ end
113
+
114
+ def duplicate_reason(dedup)
115
+ if dedup.first == :id
116
+ "same price id"
117
+ else
118
+ "same variant, currency, country, price type, role and valid_from"
119
+ end
120
+ end
121
+
122
+ def locate_by_id(row)
123
+ price = ::Spree::Price.kept.find_by(id: row[:id])
124
+ return "no price with id #{row[:id]}" if price.nil?
125
+
126
+ variant = resolve_variant(row) unless row[:variant_id].blank? && row[:sku].blank?
127
+ return "variant: #{variant}" if variant.is_a?(String)
128
+ if variant && variant.id != price.variant_id
129
+ return "price #{price.id} belongs to variant #{price.variant_id}, not #{variant.id}"
130
+ end
131
+
132
+ price_type_id = resolve_price_type_id(row)
133
+ return "price_type: #{price_type_id}" if price_type_id.is_a?(String)
134
+
135
+ attributes = row.slice(*UPDATABLE_BY_ID)
136
+ attributes[:price_type_id] = price_type_id if row.key?(:price_type_code)
137
+
138
+ [price, price.variant_id, price.price_type_id, [:id, price.id], attributes]
139
+ end
140
+
141
+ def locate_by_key(row)
142
+ variant = resolve_variant(row)
143
+ return "variant: #{variant}" if variant.is_a?(String)
144
+
145
+ price_type_id = resolve_price_type_id(row)
146
+ return "price_type: #{price_type_id}" if price_type_id.is_a?(String)
147
+
148
+ key = natural_key(row, variant, price_type_id)
149
+ price = find_existing(key) || ::Spree::Price.new(key)
150
+
151
+ [price, variant.id, price_type_id, [:key, key], row.slice(*UPDATABLE)]
152
+ end
153
+
154
+ # Each row gets its own savepoint: one bad row must not poison the batch,
155
+ # and on PostgreSQL a raised constraint error would otherwise abort every
156
+ # statement that follows it in the transaction.
157
+ def persist(index, price, variant_id, price_type_id, attributes)
158
+ ActiveRecord::Base.transaction(requires_new: true) do
159
+ existing = price.persisted?
160
+ price.assign_attributes(attributes)
161
+
162
+ if existing && !price.changed?
163
+ touch(variant_id, price_type_id, price.id)
164
+ record(index, :unchanged, price_id: price.id, variant_id: variant_id)
165
+ elsif price.save
166
+ touch(variant_id, price.price_type_id, price.id)
167
+ record(index, existing ? :updated : :created, price_id: reportable_id(price), variant_id: variant_id)
168
+ else
169
+ # Recorded before the rollback on purpose: @results is a Ruby array,
170
+ # so the savepoint unwinding the row does not unwind the report of it.
171
+ record(index, :error, variant_id: variant_id, errors: price.errors.full_messages)
172
+ raise ActiveRecord::Rollback
173
+ end
174
+ end
175
+ end
176
+
177
+ def touch(variant_id, price_type_id, price_id)
178
+ @touched_ids[[variant_id, price_type_id]] << price_id
179
+ end
180
+
181
+ # Only prices of a type the payload actually spoke about, on a variant the
182
+ # payload actually named, are in scope. A `replace` that named one sale
183
+ # price must not reach the variant's base price or its wholesale tier.
184
+ def apply_replacements
185
+ @touched_ids.each do |(variant_id, price_type_id), kept_ids|
186
+ stale = ::Spree::Price.kept.where(variant_id: variant_id, price_type_id: price_type_id).where.not(id: kept_ids)
187
+
188
+ stale.each do |price|
189
+ price.discard
190
+ @results << Result.new(index: nil, status: :deleted, price_id: price.id, variant_id: variant_id)
191
+ end
192
+ end
193
+ end
194
+
195
+ def resolve_variant(row)
196
+ if row[:variant_id].present? && row[:sku].present?
197
+ return "give variant_id or sku, not both"
198
+ end
199
+
200
+ if row[:variant_id].present?
201
+ ::Spree::Variant.find_by(id: row[:variant_id]) || "no variant with id #{row[:variant_id]}"
202
+ elsif row[:sku].present?
203
+ ::Spree::Variant.find_by(sku: row[:sku]) || "no variant with sku #{row[:sku].inspect}"
204
+ else
205
+ "variant_id or sku is required"
206
+ end
207
+ end
208
+
209
+ # Mirrors the single-price endpoint: a code is portable between databases
210
+ # where an id is not. Returns a String to signal failure, since nil is the
211
+ # legitimate value for the untyped base price.
212
+ def resolve_price_type_id(row)
213
+ has_code = row.key?(:price_type_code)
214
+ return "give price_type_id or price_type_code, not both" if has_code && row[:price_type_id].present?
215
+ return row[:price_type_id].presence && row[:price_type_id].to_i unless has_code
216
+
217
+ code = row[:price_type_code]
218
+ return nil if code.blank?
219
+
220
+ SolidusAdvancedPricing.resolve_price_type_id(code)
221
+ rescue ArgumentError
222
+ "no price type with code #{code.inspect}"
223
+ end
224
+
225
+ def natural_key(row, variant, price_type_id)
226
+ valid_from = ::Spree::Price.type_for_attribute(:valid_from).cast(row[:valid_from].presence)
227
+
228
+ {
229
+ variant_id: variant.id,
230
+ currency: row[:currency].presence || ::Spree::Config.default_pricing_options.currency,
231
+ country_iso: row[:country_iso].presence,
232
+ price_type_id: price_type_id,
233
+ role_id: row[:role_id].presence && row[:role_id].to_i,
234
+ # Truncated to the second so the key survives a round trip: a client that
235
+ # read a price back and re-sent its `valid_from` may have dropped the
236
+ # sub-second part, and treating that as a different price would duplicate
237
+ # the row -- and in `replace` mode, discard the original.
238
+ valid_from: valid_from&.change(usec: 0)
239
+ }
240
+ end
241
+
242
+ # Matching is a one-second window rather than an equality, so a price that
243
+ # was stored with sub-second precision by some other writer is still found
244
+ # by a truncated key.
245
+ def find_existing(key)
246
+ scope = ::Spree::Price.kept.where(key.except(:valid_from))
247
+ from = key[:valid_from]
248
+
249
+ return scope.find_by(valid_from: nil) if from.nil?
250
+
251
+ scope.where(valid_from: from...(from + 1.second)).first
252
+ end
253
+
254
+ # A dry run rolls back, so any id it allocated is about to stop existing.
255
+ # Reporting it would invite a client to use it.
256
+ def reportable_id(price)
257
+ dry_run? ? nil : price.id
258
+ end
259
+
260
+ def record(index, status, price_id: nil, variant_id: nil, errors: nil)
261
+ @results << Result.new(index: index, status: status, price_id: price_id, variant_id: variant_id, errors: errors)
262
+ end
263
+ end
264
+ end
@@ -49,7 +49,9 @@ module SolidusAdvancedPricing
49
49
  end
50
50
 
51
51
  def visible_to?(price, customer_role_ids)
52
- price.role_id.nil? || customer_role_ids.include?(price.role_id)
52
+ effective = price.effective_role_id
53
+
54
+ effective.nil? || customer_role_ids.include?(effective)
53
55
  end
54
56
 
55
57
  # Country is specificity, not competition: a country-specific price beats the
@@ -21,6 +21,8 @@ module SolidusAdvancedPricing
21
21
  foreign_key: :price_type_id,
22
22
  inverse_of: :price_type
23
23
 
24
+ belongs_to :role, class_name: "::Spree::Role", optional: true
25
+
24
26
  # Not `dependent: :restrict_with_error` — that check is default-scoped and
25
27
  # misses discarded prices, which then trip the FK on DELETE.
26
28
  before_destroy :prevent_destroying_referenced_type
@@ -6,10 +6,15 @@ module SolidusAdvancedPricing
6
6
  # and, later, by Spree::Price.
7
7
  module PriceTypeCache
8
8
  class << self
9
+ # Union with type-level defaults: a role that appears only on a price type
10
+ # (never directly on a price) must still narrow in, or a customer holding
11
+ # only that role would be filtered out before selection ever runs.
9
12
  def pricing_role_ids
10
13
  return @pricing_role_ids if defined?(@pricing_role_ids)
11
14
 
12
- @pricing_role_ids = ::Spree::Price.distinct.pluck(:role_id).compact
15
+ @pricing_role_ids = (
16
+ ::Spree::Price.distinct.pluck(:role_id) + PriceType.with_discarded.distinct.pluck(:role_id)
17
+ ).compact.uniq
13
18
  end
14
19
 
15
20
  # id => position, for the selector's tie-breaker. A handful of rows that
@@ -35,10 +40,23 @@ module SolidusAdvancedPricing
35
40
  ids_by_code[code.to_s]
36
41
  end
37
42
 
43
+ # id => role_id, for the effective-role fallback. with_discarded matters —
44
+ # a retired type's default role must still apply to historical prices.
45
+ def role_ids
46
+ return @role_ids if defined?(@role_ids)
47
+
48
+ @role_ids = PriceType.with_discarded.pluck(:id, :role_id).to_h
49
+ end
50
+
51
+ def role_id_for(price_type_id)
52
+ role_ids[price_type_id]
53
+ end
54
+
38
55
  def clear
39
56
  remove_instance_variable(:@pricing_role_ids) if defined?(@pricing_role_ids)
40
57
  remove_instance_variable(:@positions) if defined?(@positions)
41
58
  remove_instance_variable(:@ids_by_code) if defined?(@ids_by_code)
59
+ remove_instance_variable(:@role_ids) if defined?(@role_ids)
42
60
  end
43
61
  end
44
62
  end
@@ -6,7 +6,10 @@ en:
6
6
  solidus_advanced_pricing:
7
7
  price_types: "Price Types"
8
8
  all_customers: "All customers"
9
- role_hint: "Leave blank for all customers. A price type name alone grants no access control — an employee-only price needs this field set."
9
+ base_price: "Base price (no type)"
10
+ price_type_hint: "Leave as Base price for the standard price. A type is a label for the admin and reporting — it grants no eligibility on its own."
11
+ role_hint: "Leave blank to inherit the price type's default role, if it has one; otherwise visible to all customers. A price type name alone grants no access control — an employee-only price needs this field, or the type, set."
12
+ price_type_role_hint: "Prices of this type default to this role unless a price sets its own. Leaving a price's own role blank means it inherits this default — it does not make that price public."
10
13
  admin_notes_hint: "Internal only. Never shown to customers."
11
14
  validity: "Validity"
12
15
  solidus_admin:
@@ -22,6 +25,8 @@ en:
22
25
  one: "Price Type"
23
26
  other: "Price Types"
24
27
  attributes:
28
+ solidus_advanced_pricing/price_type:
29
+ role_id: "Default role"
25
30
  spree/price:
26
31
  price_type_id: "Price Type"
27
32
  role_id: "Visible to role"
@@ -36,9 +41,13 @@ en:
36
41
  referenced_by_prices: "This price type is still used by one or more prices and cannot be deleted. Retire it instead."
37
42
  spree/price:
38
43
  attributes:
44
+ price_type_id:
45
+ cannot_be_changed: "cannot be changed once the price exists. Discard this price and create a new one with the right type."
46
+ ambiguous_price_type: "cannot be given as both price_type_id and price_type_code. Send one or the other."
47
+ unknown_code: "no price type with code %{code}"
39
48
  valid_to:
40
49
  must_be_after_valid_from: "must be after the valid from date"
41
50
  spree/role:
42
51
  attributes:
43
52
  base:
44
- referenced_by_prices: "This role is used to target one or more prices and cannot be deleted."
53
+ referenced_by_prices: "This role is used to target one or more prices or price types and cannot be deleted."
data/config/routes.rb CHANGED
@@ -3,8 +3,15 @@
3
3
  Spree::Core::Engine.routes.draw do
4
4
  namespace :api, defaults: {format: "json"} do
5
5
  resources :variants, only: [] do
6
- resources :prices, only: [:index, :show]
6
+ resources :prices, only: [:index, :show, :create, :update, :destroy]
7
7
  end
8
+
9
+ # Not nested under a variant: a batch spans variants, which is the point of it.
10
+ post "prices/batch", to: "price_batches#create", as: :price_batch
11
+
12
+ # Read-only: a client building a price payload needs the codes and ids, but
13
+ # creating a pricing dimension is an admin act, not an API one.
14
+ resources :price_types, only: [:index]
8
15
  end
9
16
 
10
17
  namespace :admin do
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddRoleToSolidusAdvancedPricingPriceTypes < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # :integer, not :bigint — spree_roles.id is int and MySQL rejects mismatched FK precision.
6
+ add_column :solidus_advanced_pricing_price_types, :role_id, :integer
7
+ add_index :solidus_advanced_pricing_price_types, :role_id
8
+ add_foreign_key :solidus_advanced_pricing_price_types, :spree_roles, column: :role_id, on_delete: :restrict
9
+ end
10
+ end
@@ -56,6 +56,10 @@ module SolidusAdmin
56
56
  {
57
57
  header: :position,
58
58
  data: ->(price_type) { price_type.position }
59
+ },
60
+ {
61
+ header: :role_id,
62
+ data: ->(price_type) { price_type.role&.name || t("solidus_advanced_pricing.all_customers") }
59
63
  }
60
64
  ]
61
65
  end
@@ -7,7 +7,7 @@ module SolidusAdmin
7
7
  def resource_class = SolidusAdvancedPricing::PriceType
8
8
 
9
9
  def permitted_resource_params
10
- params.require(:price_type).permit(:name, :code, :position)
10
+ params.require(:price_type).permit(:name, :code, :position, :role_id)
11
11
  end
12
12
 
13
13
  # ResourcesController derives these from resource_class.model_name, which for
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Api
5
+ class PriceBatchesController < Spree::Api::BaseController
6
+ rescue_from SolidusAdvancedPricing::PriceBatch::TooManyRows, with: :batch_too_large
7
+ rescue_from SolidusAdvancedPricing::PriceBatch::InvalidMode, with: :bad_mode
8
+
9
+ # Always 200 with a per-row report, never a partial 4xx: a caller that
10
+ # sent 500 rows and got a bare 422 has no way to know which of them
11
+ # landed. The 4xx cases here are the ones where no row was even looked at.
12
+ def create
13
+ authorize! :create, Spree::Price
14
+
15
+ @batch = SolidusAdvancedPricing::PriceBatch.new(
16
+ rows: batch_rows,
17
+ mode: params.fetch(:mode, "upsert"),
18
+ dry_run: params[:dry_run]
19
+ ).call
20
+
21
+ render :create, status: :ok
22
+ end
23
+
24
+ private
25
+
26
+ def batch_rows
27
+ params.require(:prices).map do |row|
28
+ row.permit(
29
+ *Spree::PermittedAttributes.price_attributes,
30
+ :id,
31
+ :variant_id,
32
+ :sku,
33
+ :price_type_code
34
+ )
35
+ end
36
+ end
37
+
38
+ def batch_too_large(exception)
39
+ render json: {error: exception.message}, status: :unprocessable_entity
40
+ end
41
+
42
+ def bad_mode(exception)
43
+ render json: {error: exception.message}, status: :unprocessable_entity
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Api
5
+ class PriceTypesController < Spree::Api::BaseController
6
+ # Retired types are excluded: this endpoint exists so a client can build a
7
+ # valid price payload, and a discarded type is no longer a valid choice.
8
+ # Historical prices still serialize their own type's code via the prices
9
+ # endpoint, so nothing is hidden from a reader.
10
+ def index
11
+ authorize! :index, SolidusAdvancedPricing::PriceType
12
+ @price_types = SolidusAdvancedPricing::PriceType.accessible_by(current_ability, :index).ordered
13
+ respond_with(@price_types)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -15,18 +15,96 @@ module Spree
15
15
  respond_with(@price)
16
16
  end
17
17
 
18
+ def create
19
+ authorize! :create, Spree::Price
20
+
21
+ @price = variant.prices.new(currency: Spree::Config.default_pricing_options.currency)
22
+ assign_price_attributes(@price)
23
+
24
+ if @price.errors.empty? && @price.save
25
+ render :show, status: :created
26
+ else
27
+ invalid_resource!(@price)
28
+ end
29
+ end
30
+
31
+ def update
32
+ # `find` then `authorize!`, rather than an accessible_by scope, so a
33
+ # caller without rights gets 401 like every other action here instead of
34
+ # a 404 that reads as "no such price".
35
+ @price = live_prices.find(params[:id])
36
+ authorize! :update, @price
37
+
38
+ assign_price_attributes(@price)
39
+
40
+ if @price.errors.empty? && @price.save
41
+ render :show
42
+ else
43
+ invalid_resource!(@price)
44
+ end
45
+ end
46
+
47
+ # Soft delete: `discard`, not `destroy`, so the row stays available to
48
+ # anything reporting on what a variant used to cost.
49
+ def destroy
50
+ @price = live_prices.find(params[:id])
51
+ authorize! :destroy, @price
52
+
53
+ @price.discard
54
+ render plain: nil, status: :no_content
55
+ end
56
+
18
57
  private
19
58
 
20
59
  # Lists what exists for this variant; never routed through
21
60
  # current_pricing_options, which would filter by the requesting admin's
22
61
  # own roles via current_spree_user.
62
+ #
63
+ # `Spree::Variant#prices` is declared `-> { with_discarded }` in core, so
64
+ # the default here has to put `kept` back -- otherwise every listing
65
+ # includes prices someone deleted. `?show_deleted=true` opts back in, the
66
+ # same switch core uses on products.
23
67
  def scope
24
- variant.prices.accessible_by(current_ability, :index)
68
+ prices = variant.prices.accessible_by(current_ability, :index)
69
+ params[:show_deleted] ? prices : prices.kept
70
+ end
71
+
72
+ # Writes never reach a discarded price: undeleting is not an API operation,
73
+ # and silently editing a deleted row is worse than a 404.
74
+ def live_prices
75
+ variant.prices.kept
25
76
  end
26
77
 
27
78
  def variant
28
79
  @variant ||= Spree::Variant.find(params[:variant_id])
29
80
  end
81
+
82
+ # `price_type_code` is accepted as an alternative to `price_type_id` because
83
+ # ids are per-database and codes are not -- a payload written against staging
84
+ # has to work unchanged against production. An explicit null means the untyped
85
+ # base price, so a present-but-empty key is meaningful and not the same as an
86
+ # absent one.
87
+ def assign_price_attributes(price)
88
+ attributes = params.require(:price).permit(*Spree::PermittedAttributes.price_attributes, :price_type_code)
89
+
90
+ if attributes.key?("price_type_code")
91
+ code = attributes.delete("price_type_code")
92
+
93
+ if attributes.key?("price_type_id")
94
+ price.errors.add(:price_type_id, :ambiguous_price_type)
95
+ return
96
+ end
97
+
98
+ begin
99
+ attributes["price_type_id"] = code.presence && SolidusAdvancedPricing.resolve_price_type_id(code)
100
+ rescue ArgumentError
101
+ price.errors.add(:price_type_id, :unknown_code, code: code)
102
+ return
103
+ end
104
+ end
105
+
106
+ price.assign_attributes(attributes)
107
+ end
30
108
  end
31
109
  end
32
110
  end
@@ -14,7 +14,7 @@ module Spree
14
14
  end
15
15
 
16
16
  def permitted_resource_params
17
- params.require(:price_type).permit(:name, :code, :position)
17
+ params.require(:price_type).permit(:name, :code, :position, :role_id)
18
18
  end
19
19
 
20
20
  # ResourceController derives these from model_class.model_name, which for a
@@ -2,9 +2,20 @@
2
2
 
3
3
  module SolidusAdvancedPricing
4
4
  class Configuration
5
- # Define here the settings for this extension, e.g.:
6
- #
7
- # attr_accessor :my_setting
5
+ # Largest payload PriceBatch will accept in one call. A synchronous request
6
+ # has to stay inside the web timeout; anything bigger belongs in a job.
7
+ attr_writer :batch_row_limit
8
+
9
+ # Optional callable invoked by PriceBatch once every row is written and
10
+ # before the transaction commits, receiving the batch. Raise from it to
11
+ # abort. This is the seam for store policy -- "refuse a batch that moves
12
+ # more than N% of prices by more than X%" -- which does not belong in a
13
+ # general-purpose extension but does need somewhere to stand.
14
+ attr_accessor :batch_guard
15
+
16
+ def batch_row_limit
17
+ @batch_row_limit ||= 500
18
+ end
8
19
  end
9
20
 
10
21
  class << self
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidusAdvancedPricing
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,6 @@
1
+ json.mode @batch.mode
2
+ json.dry_run @batch.dry_run?
3
+ json.summary @batch.summary
4
+ json.results(@batch.results) do |result|
5
+ json.merge! result.to_h
6
+ end
@@ -0,0 +1 @@
1
+ json.call(price_type, :id, :code, :name, :position, :role_id)
@@ -0,0 +1,3 @@
1
+ json.price_types(@price_types) do |price_type|
2
+ json.partial!("spree/api/price_types/price_type", price_type: price_type)
3
+ end
@@ -17,4 +17,15 @@
17
17
  <%= f.number_field :position, class: 'fullwidth' %>
18
18
  <% end %>
19
19
  </div>
20
+ <div class="col-6">
21
+ <%= f.field_container :role do %>
22
+ <%= f.label :role_id, Spree::Role.model_name.human %>
23
+ <%= f.collection_select :role_id,
24
+ Spree::Role.order(:name),
25
+ :id, :name,
26
+ { include_blank: t('solidus_advanced_pricing.all_customers') },
27
+ { class: 'custom-select fullwidth' } %>
28
+ <span class="field-hint"><%= t('solidus_advanced_pricing.price_type_role_hint') %></span>
29
+ <% end %>
30
+ </div>
20
31
  </div>
@@ -12,6 +12,7 @@
12
12
  <th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:name) %></th>
13
13
  <th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:code) %></th>
14
14
  <th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:position) %></th>
15
+ <th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:role_id) %></th>
15
16
  <th class="actions"></th>
16
17
  </tr>
17
18
  </thead>
@@ -21,6 +22,7 @@
21
22
  <td><%= price_type.name %></td>
22
23
  <td><%= price_type.code %></td>
23
24
  <td><%= price_type.position %></td>
25
+ <td><%= price_type.role&.name || t('solidus_advanced_pricing.all_customers') %></td>
24
26
  <td class="actions">
25
27
  <%= link_to_edit price_type, no_text: true %>
26
28
  <%= link_to_delete price_type, no_text: true %>