solidus_advanced_pricing 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1750fd578f0dd63061c1ad728e7418e11bd128ee4bd18d602b58bdc43d6d84c1
4
- data.tar.gz: e56ded8723862a6ac3513dd4ac9df9a5f074a7a5dfcc41664d595a440c67cbf4
3
+ metadata.gz: 0feded43570200af31d61d2822614677e232d696b480196576c0651edafee278
4
+ data.tar.gz: 20b1184e197fc387a095d5af68b7936fc894ccd213b6386a259bff8f0e77e6e5
5
5
  SHA512:
6
- metadata.gz: 158337d85d20a1d2099954234f17a1adf6c3e5eef14369a60379bb690edfd9fd42874dd442a17980a89427f1d2d3cfff14f460350ed173cd3bde65a582954ee3
7
- data.tar.gz: ca2d434c231a0e0ea9d9e9bc88730d39b66455a9296cce9809935337ff7d0b5fd7e54eb35471aed2456e3799a1ec4241a4f4d99db2d6fb7f5660d975061bd09d
6
+ metadata.gz: c2a90ff27650243c41246a0dfa7b9cd1b6a672c73060dcf6abcaf007f101baefdf3d627c28301ac06e576b1a7bb1a4f9602135935a6cbe2327ea62918fa20d20
7
+ data.tar.gz: 5b57c0ee2ef362b15e377db52ebd80c1624db43c3c94d69846e8e93bfacb7ce17864766f35cb9ee56c8113a424b3afb5a6d550be20b18872eefa6f8bd04ef595
data/.gitignore CHANGED
@@ -19,3 +19,4 @@ spec/examples.txt
19
19
  .rvmrc
20
20
  .ruby-version
21
21
  .ruby-gemset
22
+ docs/superpowers
data/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0] - 2026-09-23
8
+
9
+ ### Added
10
+
11
+ - **Write endpoints for prices.** `POST`, `PATCH` and `DELETE` on
12
+ `/api/variants/:variant_id/prices`, so prices can be administered over the API rather
13
+ than only read. `DELETE` soft-deletes.
14
+ - **`price_type_code` is accepted anywhere `price_type_id` is** on a price payload. Ids
15
+ are per-database and codes are not, so a payload written against staging works unchanged
16
+ against production. Sending both is a `422`; an explicit null means the untyped base
17
+ price.
18
+ - **`GET /api/price_types`**, listing the types a price payload may reference with their
19
+ ids, codes, names, positions and default roles. Read-only and admin-authorized.
20
+ - **Batch writes: `POST /api/prices/batch`** and the `SolidusAdvancedPricing::PriceBatch`
21
+ service behind it. Many prices across many variants in one call, matched on the natural
22
+ key (variant, currency, country, price type, role, `valid_from`) so re-sending a payload
23
+ is a no-op rather than a pile of duplicates. Always answers `200` with a per-row report.
24
+ - `dry_run: true` runs the real work in a transaction and rolls it back, so the report
25
+ reflects what the write would do rather than a guess at it.
26
+ - A row carrying `id` names that price outright: `variant_id`/`sku` become optional and
27
+ are checked rather than applied, natural-key fields such as `currency`, `role_id` and
28
+ `valid_from` become editable, and an unknown or deleted `id` is a row error rather than
29
+ a new price.
30
+ - `mode: "replace"` also discards prices the payload left out — scoped to price types and
31
+ variants the payload actually named.
32
+ - Rows are applied independently in savepoints, so one bad row does not take the batch
33
+ down and a row-wise retry is possible.
34
+ - `SolidusAdvancedPricing.config.batch_row_limit` (default 500) and
35
+ `config.batch_guard`, a callable invoked with every row written and nothing committed,
36
+ which may raise to abort. The seam for store policy that does not belong in the gem.
37
+ - `currency` now defaults to `Spree::Config.default_pricing_options.currency` when a
38
+ created price omits it.
39
+
40
+ ### Fixed
41
+
42
+ - **`GET /api/variants/:variant_id/prices` listed soft-deleted prices.**
43
+ `Spree::Variant#prices` is declared `-> { with_discarded }` in core, which the endpoint
44
+ inherited, so anything an admin had deleted still came back. Deleted prices are now
45
+ excluded by default; `?show_deleted=true` opts back in.
46
+
47
+ ### Changed
48
+
49
+ - **`price_type` is now immutable on a persisted price**, raising a validation error
50
+ instead of silently retyping the row. The admin form has disabled that select since
51
+ 0.2.0; this closes the same gap at the model layer so the API cannot route around it.
52
+ Re-sending an unchanged `price_type_id` is still fine. Code that deliberately retypes an
53
+ existing price must now discard it and create a replacement, or use `update_columns` to
54
+ bypass validation in a data migration.
55
+
7
56
  ## [0.2.0] - 2026-09-23
8
57
 
9
58
  ### Added
@@ -64,5 +113,6 @@ Initial release.
64
113
  bucket. Without the role component a role-targeted price would be cached and served to
65
114
  guests.
66
115
 
116
+ [0.3.0]: https://github.com/fcpeuro/solidus_advanced_pricing/compare/v0.2.0...v0.3.0
67
117
  [0.2.0]: https://github.com/fcpeuro/solidus_advanced_pricing/compare/v0.1.0...v0.2.0
68
118
  [0.1.0]: https://github.com/fcpeuro/solidus_advanced_pricing/releases/tag/v0.1.0
data/README.md CHANGED
@@ -302,15 +302,20 @@ against pre-1.0 `solidus_admin` internals (`SolidusAdmin::UI::Pages::Index::Comp
302
302
  ## API
303
303
 
304
304
  ```
305
- GET /api/variants/:variant_id/prices
306
- GET /api/variants/:variant_id/prices/:id
305
+ GET /api/variants/:variant_id/prices
306
+ GET /api/variants/:variant_id/prices/:id
307
+ POST /api/variants/:variant_id/prices
308
+ PATCH /api/variants/:variant_id/prices/:id
309
+ DELETE /api/variants/:variant_id/prices/:id
310
+
311
+ GET /api/price_types
307
312
  ```
308
313
 
309
314
  Each price is serialized with its advanced attributes — `price_type_code`,
310
315
  `price_type_name`, `role_id`, `valid_from`, `valid_to` — and `admin_notes` is included
311
316
  only for a caller who can update that price.
312
317
 
313
- **This endpoint is admin-facing.** Core's `DefaultCustomer` permission set (what every
318
+ **These endpoints are admin-facing.** Core's `DefaultCustomer` permission set (what every
314
319
  non-admin API token gets) grants no rights on `Spree::Price` at all, so a non-admin
315
320
  token receives `401 Unauthorized` on the whole endpoint, not a filtered response.
316
321
  Headless storefronts do not need this endpoint for normal pricing: core's variants
@@ -318,6 +323,179 @@ endpoint already serializes `price` and `display_price` through this gem's selec
318
323
  automatically, because the selector is registered globally via
319
324
  `Spree::Config.variant_price_selector_class`.
320
325
 
326
+ ### Writing prices
327
+
328
+ ```shell
329
+ curl -X POST https://store.example/api/variants/42/prices \
330
+ -H "Authorization: Bearer $SPREE_API_KEY" \
331
+ -H "Content-Type: application/json" \
332
+ -d '{"price": {"amount": "35.00", "price_type_code": "sale",
333
+ "valid_from": "2026-11-27T00:00:00Z",
334
+ "valid_to": "2026-12-02T00:00:00Z"}}'
335
+ ```
336
+
337
+ - **`price_type_code` is accepted anywhere `price_type_id` is**, and is the better choice
338
+ for anything scripted: ids differ between your staging and production databases, codes
339
+ do not. Sending both is a `422`. Sending `price_type_code: null` (or `""`) means the
340
+ untyped base price, the same as omitting `price_type_id`.
341
+ - **`currency` defaults to `Spree::Config.default_pricing_options.currency`** when the
342
+ payload omits it.
343
+ - **`price_type` cannot be changed once a price exists.** The admin form has disabled that
344
+ select on persisted prices since 0.2.0, and the model now enforces it, so the API can't
345
+ route around it. Retyping a row reinterprets history instead of correcting it; to fix a
346
+ mistyped price, delete it and create the right one. Re-sending the *same*
347
+ `price_type_id` on an update is fine, so ordinary read-modify-write clients are
348
+ unaffected.
349
+ - **`DELETE` soft-deletes** (sets `deleted_at`), so the row stays available to anything
350
+ reporting on what a variant used to cost. Deleted prices are excluded from the listing;
351
+ pass `?show_deleted=true` to include them. Writes never reach a deleted price — updating
352
+ one is a `404`.
353
+
354
+ Remember that the selector takes the **cheapest** eligible price
355
+ ([how a price is chosen](#how-a-price-is-chosen)). A role-targeted price written *above*
356
+ the untargeted price will never apply, and the API will not warn you about it.
357
+
358
+ Nothing here stops you deleting a variant's only open-ended price, which leaves that
359
+ variant unpriced outside its remaining windows (see
360
+ [Backward compatibility](#backward-compatibility)). If that matters to your store, assert
361
+ it on your side.
362
+
363
+ ### Batch writes
364
+
365
+ ```
366
+ POST /api/prices/batch
367
+ ```
368
+
369
+ One call, many prices, across many variants. Always answers `200` with a per-row report,
370
+ never a partial `4xx` — a caller that sent 500 rows and got a bare `422` has no way to
371
+ know which of them landed.
372
+
373
+ ```shell
374
+ curl -X POST https://store.example/api/prices/batch \
375
+ -H "Authorization: Bearer $SPREE_API_KEY" \
376
+ -H "Content-Type: application/json" \
377
+ -d '{
378
+ "mode": "upsert",
379
+ "dry_run": true,
380
+ "prices": [
381
+ {"sku": "ABC-123", "amount": "35.00", "price_type_code": "sale"},
382
+ {"sku": "DEF-456", "amount": "45.00", "price_type_code": "sale"},
383
+ {"id": 907, "amount": "29.99"}
384
+ ]
385
+ }'
386
+ ```
387
+
388
+ ```json
389
+ {
390
+ "mode": "upsert",
391
+ "dry_run": true,
392
+ "summary": {"created": 1, "updated": 1},
393
+ "results": [
394
+ {"index": 0, "status": "created", "variant_id": 42},
395
+ {"index": 1, "status": "updated", "price_id": 907, "variant_id": 43}
396
+ ]
397
+ }
398
+ ```
399
+
400
+ Each row takes the same attributes as a single price, plus `id` and `sku`. Per-row
401
+ `status` is `created`, `updated`, `unchanged`, `deleted` or `error`; an `error` row carries
402
+ `errors` and nothing was written for it.
403
+
404
+ **Send it as JSON.** Form encoding cannot represent an array of hashes whose rows have
405
+ different keys — Rack starts a new hash only when it meets a key it has already seen, so
406
+ `[{"id": 1, "amount": 2}, {"variant_id": 3, "amount": 4}]` arrives as
407
+ `[{"id": 1, "amount": 2, "variant_id": 3}, {"amount": 4}]`. Set
408
+ `Content-Type: application/json`.
409
+
410
+ #### Naming a row: `id`, or the natural key
411
+
412
+ **If a row carries `id`, that is the price it changes** — nothing is inferred, nothing can
413
+ be mismatched, and `variant_id`/`sku` become optional (the price already knows its
414
+ variant; supply one and it is checked, not applied). An `id` that does not exist, or that
415
+ has been deleted, is an `error` on that row rather than a new price. This is the right
416
+ shape for read-modify-write: fetch the prices, change what you need, send them back.
417
+
418
+ **Without an `id`, the row is matched on the natural key** — variant, currency, country,
419
+ price type, role and `valid_from`. Those are the dimensions a price legitimately varies
420
+ on, so an upsert on them is unambiguous, and re-sending the same payload is a no-op rather
421
+ than a pile of duplicates.
422
+
423
+ That second path is not redundant with the first. A payload authored where Solidus ids are
424
+ unknown — a supplier feed, a merchandiser's spreadsheet, a backfill from another system —
425
+ has no ids to send, and `id`-or-create alone would make every re-run duplicate the whole
426
+ file. The natural key is what makes a nightly feed idempotent.
427
+
428
+ `valid_to` is deliberately *not* in the key: extending or shortening a window edits the
429
+ price you already have. `valid_from` is matched to the second, because a client that read a
430
+ price back and re-sent its `valid_from` may have dropped the sub-second part in
431
+ serialization, and treating that as a different price would duplicate the row — and under
432
+ `replace`, discard the original. Sending the `id` sidesteps that question entirely.
433
+
434
+ Two rows in one payload naming the same price — by `id` or by key — are an `error` on the
435
+ second, not a silent last-one-wins.
436
+
437
+ Fields in the natural key (`currency`, `country_iso`, `role_id`, `valid_from`) can only be
438
+ *changed* by a row that names the price by `id`; on a keyed row they are how the price was
439
+ found. `price_type` cannot be changed either way — see
440
+ [Writing prices](#writing-prices).
441
+
442
+ #### Modes
443
+
444
+ - **`upsert`** (default) creates or updates the rows you send and touches nothing else.
445
+ - **`replace`** additionally discards prices the payload *left out* — but only of a price
446
+ type the payload named, on a variant the payload named. A `replace` that sends one sale
447
+ price will not reach that variant's base price or its wholesale tier, and will not reach
448
+ any other variant. Deleted rows appear in the report with `status: "deleted"`.
449
+
450
+ #### `dry_run`
451
+
452
+ `dry_run: true` does the real work against the database inside a transaction and rolls it
453
+ back, so the report reflects what the write would actually do — validations, type casting
454
+ and constraints included — rather than a guess at it. Ids are omitted from `created` rows
455
+ on a dry run, since they are about to stop existing.
456
+
457
+ **Use it.** The failure mode of a bulk price load is a silent one.
458
+
459
+ #### Atomicity
460
+
461
+ Rows are applied independently, each in its own savepoint: one bad row does not take the
462
+ batch down, and the successful rows are committed. This is what makes a row-wise retry
463
+ possible. If you need all-or-nothing, run the payload with `dry_run: true` first and only
464
+ send it for real once the report is clean.
465
+
466
+ #### Limits and store policy
467
+
468
+ `Spree::Config` is not involved; the extension has its own configuration:
469
+
470
+ ```ruby
471
+ SolidusAdvancedPricing.configure do |config|
472
+ config.batch_row_limit = 500 # default
473
+
474
+ # Called once every row is written and before the transaction commits.
475
+ # Raise to abort the whole batch.
476
+ config.batch_guard = ->(batch) do
477
+ raise TooMuchMovement if batch.summary.fetch(:updated, 0) > 200
478
+ end
479
+ end
480
+ ```
481
+
482
+ A batch over `batch_row_limit` is refused with a `422` before any row is looked at — a
483
+ synchronous request has to stay inside the web timeout, and anything larger belongs in a
484
+ background job.
485
+
486
+ `batch_guard` is the seam for store policy — "refuse a batch that moves more than N% of
487
+ prices by more than X%" — which does not belong in a general-purpose extension but does
488
+ need somewhere to stand where it can see the finished picture and still stop it.
489
+
490
+ ### Reading price types
491
+
492
+ `GET /api/price_types` lists the types a price payload may reference — `id`, `code`,
493
+ `name`, `position` and the type's default `role_id` — in `position` order. Retired
494
+ (discarded) types are omitted, since they are no longer a valid choice; historical prices
495
+ still report their own type's code through the prices endpoints. The endpoint is
496
+ read-only and admin-authorized: creating a pricing dimension is an admin act, not an API
497
+ one.
498
+
321
499
  ## Storefront: compare-at (strikethrough) pricing
322
500
 
323
501
  `Spree::Variant#price_of_type` and `#base_price` fetch a specific typed price
@@ -23,6 +23,7 @@ module SolidusAdvancedPricing
23
23
  optional: true
24
24
 
25
25
  base.validate :valid_to_after_valid_from
26
+ base.validate :price_type_is_immutable
26
27
 
27
28
  # valid_to is exclusive so a window ending at midnight and the next one starting at midnight do not both match.
28
29
  base.scope :valid_at, ->(time) {
@@ -63,6 +64,17 @@ module SolidusAdvancedPricing
63
64
 
64
65
  private
65
66
 
67
+ # The admin form has disabled this select on persisted records since 0.2.0;
68
+ # the API would otherwise be a way around that. Retyping an existing row
69
+ # reinterprets history rather than correcting it -- the fix for a
70
+ # mistyped price is to discard it and create the right one.
71
+ def price_type_is_immutable
72
+ return if new_record?
73
+ return unless price_type_id_changed?
74
+
75
+ errors.add(:price_type_id, :cannot_be_changed)
76
+ end
77
+
66
78
  def valid_to_after_valid_from
67
79
  return if valid_from.blank? || valid_to.blank?
68
80
  return if valid_to > valid_from
@@ -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
@@ -41,6 +41,10 @@ en:
41
41
  referenced_by_prices: "This price type is still used by one or more prices and cannot be deleted. Retire it instead."
42
42
  spree/price:
43
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}"
44
48
  valid_to:
45
49
  must_be_after_valid_from: "must be after the valid from date"
46
50
  spree/role:
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,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
@@ -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.2.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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solidus_advanced_pricing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick McMorran
@@ -156,6 +156,7 @@ files:
156
156
  - app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb
157
157
  - app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb
158
158
  - app/decorators/models/solidus_advanced_pricing/spree/variant_decorator.rb
159
+ - app/models/solidus_advanced_pricing/price_batch.rb
159
160
  - app/models/solidus_advanced_pricing/price_selector.rb
160
161
  - app/models/solidus_advanced_pricing/price_type.rb
161
162
  - app/models/solidus_advanced_pricing/price_type_cache.rb
@@ -179,9 +180,10 @@ files:
179
180
  - db/migrate/20260915000002_seed_solidus_advanced_pricing_price_types.rb
180
181
  - db/migrate/20260915000003_add_advanced_pricing_to_spree_prices.rb
181
182
  - db/migrate/20260923000001_add_role_to_solidus_advanced_pricing_price_types.rb
182
- - docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md
183
183
  - lib/components/admin/solidus_admin/price_types/index/component.rb
184
184
  - lib/controllers/admin/solidus_admin/price_types_controller.rb
185
+ - lib/controllers/api/spree/api/price_batches_controller.rb
186
+ - lib/controllers/api/spree/api/price_types_controller.rb
185
187
  - lib/controllers/api/spree/api/prices_controller.rb
186
188
  - lib/controllers/backend/spree/admin/price_types_controller.rb
187
189
  - lib/generators/solidus_advanced_pricing/install/install_generator.rb
@@ -192,6 +194,9 @@ files:
192
194
  - lib/solidus_advanced_pricing/testing_support/factories.rb
193
195
  - lib/solidus_advanced_pricing/testing_support/factories/price_type_factory.rb
194
196
  - lib/solidus_advanced_pricing/version.rb
197
+ - lib/views/api/spree/api/price_batches/create.json.jbuilder
198
+ - lib/views/api/spree/api/price_types/_price_type.json.jbuilder
199
+ - lib/views/api/spree/api/price_types/index.json.jbuilder
195
200
  - lib/views/api/spree/api/prices/_price.json.jbuilder
196
201
  - lib/views/api/spree/api/prices/index.json.jbuilder
197
202
  - lib/views/api/spree/api/prices/show.json.jbuilder
@@ -1,351 +0,0 @@
1
- # solidus_advanced_pricing — Design
2
-
3
- Status: FINAL (approved design, ready for implementation planning)
4
- Date: 2026-09-14
5
-
6
- ## Problem
7
-
8
- Solidus core models a price as `(variant, currency, country_iso, amount)`. There is no way to
9
- express that a price is a particular *kind* of price, that it applies only during a window of
10
- time, or that it applies only to a certain kind of customer.
11
-
12
- Stores that need wholesale or dealer pricing, scheduled price-list rollovers, or simply a record
13
- of why a price exists currently reach for promotions. Promotions are the wrong tool for the
14
- first two: they *discount* a price rather than replacing it, which leaks the retail price to a
15
- B2B customer, reads as a discount on invoices, and reports as promotional revenue when it is
16
- not. See the promotions decision below for the full evaluation.
17
-
18
- ## Goal
19
-
20
- A Solidus extension gem adding three things to `Spree::Price`, plus internal commentary:
21
-
22
- 1. Configurable, admin-managed **price types**
23
- 2. An optional **validity window** (`valid_from` / `valid_to`)
24
- 3. Optional **role targeting** — one `Spree::Role` a price is visible to
25
- 4. **`admin_notes`** — internal-facing commentary on why a price exists
26
-
27
- ...delivered through Solidus' existing `Spree::Config.variant_price_selector_class` seam rather
28
- than by monkey-patching price lookup.
29
-
30
- ## Non-goals
31
-
32
- - **Promotion machinery** — lanes, codes, usage limits, stacking rules, order-level
33
- adjustments. A windowed `sale` or `clearance` price *is* supported; anything needing
34
- promotional logic belongs in `solidus_promotions`.
35
- - **Role exclusion.** Only inclusion is expressible. "Everyone except employees" requires one
36
- targeted price per included role.
37
- - **Re-pricing existing line items.** Carts are left alone, matching core.
38
- - **A prices screen in solidus_admin.** Upstream has not built one; this gem will not guess it.
39
- - **Display-only / compare-at price types.** Every price is a selection candidate.
40
-
41
- ## Architecture
42
-
43
- Two subclasses registered in one initializer line. Core delegates `pricing_options_class` from
44
- the selector class, so this wires both:
45
-
46
- ```ruby
47
- Spree::Config.variant_price_selector_class = "SolidusAdvancedPricing::PriceSelector"
48
- ```
49
-
50
- Everything else is additive columns, one new table, view overrides, and an API controller.
51
-
52
- ## Data model
53
-
54
- ```
55
- solidus_advanced_pricing_price_types
56
- id, name, code (unique), position, default (boolean), deleted_at, timestamps
57
-
58
- spree_prices (columns added)
59
- price_type_id bigint, NOT NULL, FK → price_types, indexed
60
- role_id bigint, NULL, FK → spree_roles, indexed
61
- valid_from datetime, NULL (nil = open-ended start)
62
- valid_to datetime, NULL (nil = never expires)
63
- admin_notes text, NULL
64
- index (variant_id, currency, country_iso, role_id, valid_from, valid_to)
65
- ```
66
-
67
- `role_id` is modeled on `country_iso`: nullable, where `nil` means "every customer, including
68
- guests". There is no join table and no exclusion.
69
-
70
- The install migration seeds five price types, keyed by `code` so re-running is idempotent,
71
- then backfills every existing price into `default` before adding the NOT NULL constraint:
72
-
73
- | code | position | default | notes |
74
- |---|---|---|---|
75
- | `default` | 1 | yes | backfill target; guarded against discard |
76
- | `wholesale` | 2 | | |
77
- | `sale` | 3 | | typically paired with a validity window |
78
- | `clearance` | 4 | | |
79
- | `employee` | 5 | | typically paired with `role_id` |
80
-
81
- Only `default` is structural. The other four are conveniences — admins may rename, reposition
82
- or discard them freely, and may add their own.
83
-
84
- **A price type grants no access control.** Type and `role_id` are independent columns: naming a
85
- price `employee` does not hide it from anyone. An employee-only price is
86
- `price_type: employee` **and** `role_id: <employee role>`. This is the most likely
87
- misconfiguration in the whole gem and must be called out in both the README and the admin form
88
- hint text.
89
-
90
- ## Price selection
91
-
92
- `SolidusAdvancedPricing::PricingOptions < Spree::Variant::PricingOptions`
93
-
94
- `desired_attributes` gains two real, assignable `Spree::Price` columns:
95
-
96
- - `price_type_id` — pinned to the default type in `default_price_attributes`
97
- - `role_id` — pinned to `nil` in `default_price_attributes`
98
-
99
- Both must be assignable columns because core calls `prices.build(default_price_attributes)` in
100
- `DefaultPrice#default_price_or_build`. They also make core's public `Spree::Price
101
- .with_default_attributes` scope correct for consumers.
102
-
103
- Two contextual readers live *outside* `desired_attributes`, because they are not price columns:
104
-
105
- | reader | default | meaning |
106
- |---|---|---|
107
- | `at` | `Time.current` | only prices whose window contains this instant |
108
- | `customer_role_ids` | `[]` | the roles this customer holds; `[]` is a guest |
109
-
110
- Built from context as:
111
-
112
- - `from_context(ctx)` → `customer_role_ids: ctx.current_spree_user&.spree_role_ids || []`
113
- - `from_line_item(li)` → `customer_role_ids: li.order&.user&.spree_role_ids || []`
114
-
115
- The association is `spree_roles` (see `Spree::UserMethods`), so the reader is `spree_role_ids`,
116
- not `role_ids`.
117
-
118
- **Pricing-relevant narrowing.** Both constructors intersect the customer's roles with the set of
119
- roles that actually appear on some price — `Spree::Price.distinct.pluck(:role_id).compact`,
120
- cached and invalidated by a `Spree::Price` `after_commit`. A customer holding
121
- `[admin, wholesale, newsletter]` narrows to `[wholesale]`. This changes no selection outcome —
122
- a role no price references can never match — but it collapses cache-key cardinality from
123
- 2^(every role in the store) to 2^(roles used in pricing), which in practice is zero to two.
124
-
125
- `SolidusAdvancedPricing::PriceSelector < Spree::Variant::PriceSelector`
126
-
127
- `#price_for_options` runs in memory over `variant.prices`, as core does, so unsaved prices and
128
- preloaded associations keep working. Order of operations:
129
-
130
- 1. **Filter** — drop discarded (unless the variant itself is discarded, per core); currency must
131
- match exactly; the window must contain `at`; and the price must be visible:
132
- `price.role_id.nil? || customer_role_ids.include?(price.role_id)`
133
- 2. **Country specificity** — if any survivor matches the desired `country_iso`, keep only those;
134
- otherwise keep the `country_iso IS NULL` fallback. This preserves core's behavior exactly:
135
- country is specificity, not competition.
136
- 3. **Cheapest wins** — `min_by(&:amount)` across the surviving bucket. Ties break on price type
137
- `position`, then `updated_at` desc, then `id` desc, mirroring core's ordering.
138
-
139
- Role is **eligibility only** (step 1), never specificity. A wholesale customer sees both the
140
- wholesale and the untargeted price and gets whichever is cheaper. A wholesale price set *above*
141
- retail therefore never applies — that is intended and must be documented prominently, since it
142
- is the one genuinely surprising consequence of this model.
143
-
144
- The same predicates exist as AR scopes — `valid_at(time)`, `visible_to_roles(ids)`,
145
- `for_price_type(x)` — and `Spree::Variant.with_prices` is overridden in a decorator to use them.
146
- Without that override, a variant whose only price expired yesterday still counts as purchasable
147
- in product listings, because core's `with_prices` checks only currency and country.
148
-
149
- ## Backward compatibility
150
-
151
- The guarantee: **a store with no typed, windowed or targeted prices behaves identically to
152
- core.** This gets an explicit spec that fails loudly, because every other compatibility claim
153
- rests on it.
154
-
155
- It holds because the backfill gives every existing price the default type, `role_id` defaults to
156
- `nil` (visible to all), and both window bounds default to `nil` (always valid). Defaulting `at`
157
- to `Time.current` changes nothing for a price with no window.
158
-
159
- One consequence to document: if a variant's only base prices all have windows and none is
160
- currently valid, `variant.price` returns `nil`, exactly as core returns `nil` when no price
161
- matches. Stores should keep one open-ended base price per variant.
162
-
163
- ## Admin surfaces
164
-
165
- **Legacy backend.** `spree/admin/prices/_form` and `_table` both carry `data-hook` attributes, so
166
- Deface inserts at `admin_product_price_fields`: a price type select, a role select (mirroring the
167
- existing country select, blank = all customers), `valid_from`/`valid_to` datetime fields, and an
168
- `admin_notes` textarea. The prices index gains type, role and window columns.
169
- `Spree::Admin::ResourceController#permitted_resource_params` calls `permit!`, so the new columns
170
- need no strong-params work.
171
-
172
- Plus `Spree::Admin::PriceTypesController` — standard `ResourceController` CRUD.
173
-
174
- **solidus_admin.** A price types CRUD only, mirroring the existing `adjustment_reasons` /
175
- `refund_reasons` resource components. Per-variant price management stays in the legacy backend.
176
-
177
- ## API
178
-
179
- Core's API has no prices resource; variants serialize only `price` / `display_price` via
180
- `price_for_options(current_pricing_options)`, which the new selector improves for free.
181
-
182
- v1 adds `Spree::Api::PricesController` plus jbuilder views exposing price type, role, validity
183
- window, and `admin_notes`. Two rules:
184
-
185
- - **Explicit pricing options, never `current_pricing_options`.** Core builds those
186
- `from_context`, which reads `current_spree_user` — on an admin-token request that would
187
- silently filter storefront prices by the *admin's* roles.
188
- - **`admin_notes` is serialized only when `can?(:update, price)`.** It is internal commentary and
189
- must never reach a storefront payload.
190
-
191
- ## Caching
192
-
193
- `PricingOptions#cache_key` in core is `desired_attributes.values.select(&:present?).join("/")`.
194
- Since `customer_role_ids` and `at` deliberately live outside that hash, an unmodified
195
- `cache_key` would render a wholesale customer's price into a fragment cache and then serve it to
196
- guests. The subclass **must** override `cache_key` to include sorted `customer_role_ids`.
197
-
198
- Note that having `role_id` in `desired_attributes` does *not* solve this. That attribute
199
- describes the price being sought, not the customer asking: `from_context` leaves it `nil`, so it
200
- is `nil` for guest and wholesale customer alike — and `select(&:present?)` drops nils regardless.
201
- Making core's key work unmodified would mean collapsing the customer to a single role, which
202
- cannot express "visible to any of my roles, cheapest wins".
203
-
204
- Time is the harder half: keying on raw `at` gives a 0% hit rate, while omitting it serves prices
205
- from cache past their window. v1 ships a coarse bucket —
206
- `Spree::Config.advanced_pricing_cache_granularity`, default 60 seconds — with the trade-off
207
- documented plainly: a price transition can be up to one granularity window late in cached views.
208
- Stores that cannot tolerate that set it to `nil` and lose price-dependent fragment caching.
209
-
210
- ## Validations and edge cases
211
-
212
- - `valid_to` must be after `valid_from` when both are present.
213
- - Price types are soft-deletable. Discarding one removes it from the new-price dropdown but
214
- leaves historical prices intact and still selectable, so "Overstock Sale of 2012" keeps its
215
- type forever.
216
- - The `default: true` type is guarded against both discard and having its flag cleared while it
217
- is the only default.
218
- - `role_id` and `price_type_id` integrity is enforced by foreign keys.
219
-
220
- ## Testing
221
-
222
- - Model and scope specs, including the price type default/discard guards.
223
- - Selector specs driven by `ActiveSupport::Testing::TimeHelpers` across window boundaries.
224
- - An explicit backward-compatibility spec asserting `default_pricing_options` behavior is
225
- unchanged from core.
226
- - A cache-key spec asserting two different role sets never collide.
227
- - Request specs for the API, including the `admin_notes` authorization boundary.
228
- - Feature specs for the legacy backend; component specs for the solidus_admin price types CRUD.
229
- - Factories under `lib/solidus_advanced_pricing/testing_support/factories.rb`, per contrib
230
- convention.
231
-
232
- ## Packaging
233
-
234
- `solidus_dev_support` skeleton: dummy app, RSpec, RuboCop, CI matrix. The engine uses
235
- `solidus_support`'s `backend_available?` / `admin_available?` / `api_available?` guards to add
236
- engine paths conditionally, so a headless store without `solidus_backend` does not load Deface
237
- overrides it cannot use. Solidus floor is 4.0 (`solidus_admin` exists only from 4.3, hence a
238
- guard rather than a hard dependency).
239
-
240
- ## Decisions
241
-
242
- Every explicit decision from the brainstorm, with rationale.
243
-
244
- - **Price types are an admin-managed model**, not a config list or free-form string.
245
- Rationale: store admins can add types without a deploy, and `position` gives a deterministic
246
- tie-breaker.
247
- - **v1 ships the full stack**: model + price-selection core, legacy backend admin UI,
248
- solidus_admin UI, and REST API exposure. (The solidus_admin half was later scoped down to a
249
- price types CRUD — see that decision below.)
250
- Rationale: an extension nobody can edit or read over the API isn't usable in a real store.
251
- - **Role targeting uses `Spree::Role`**, not a gem-owned group model or lambda predicates.
252
- Rationale: works out of the box with solidus_auth_devise and any roles a store already
253
- defines; keeps targeting queryable in SQL.
254
- - **Public contrib-style gem**: broad Solidus support, solidus_dev_support dummy app, CI matrix,
255
- README, released to RubyGems.
256
- Rationale: the feature is generally useful; building to contrib conventions from the start is
257
- cheaper than retrofitting them.
258
- - **No display-only price types.** Every price is a selection candidate; compare-at/MSRP display
259
- is the storefront's concern.
260
- Rationale: keeps the model small; a `sellable` flag can be added later without breaking
261
- anything.
262
- - **Stale carts are left alone.** A line item keeps its captured price when the source price
263
- expires or the customer loses a role; the gem does not hook the order updater.
264
- Rationale: matches core, which never re-prices line items on its own.
265
- - **Additive columns on `spree_prices`** plus a gem-owned price types table, rather than a
266
- sidecar `price_details` table or a parallel prices table.
267
- Rationale: the selector stays a single joinable query and eligibility becomes composable AR
268
- scopes, instead of the LEFT-OUTER-JOIN dance a sidecar would force on every lookup.
269
- - **`price_type_id` is NOT NULL**; the install migration seeds a `default` type and backfills
270
- every existing price into it.
271
- Rationale: "every price has a type" is a far easier invariant to hold than "nil is secretly a
272
- type", and admins see a real name in the UI.
273
- - **Filter first, then cheapest wins.** Currency, window and role visibility narrow the
274
- candidates; country specificity picks the bucket; the lowest amount wins within it.
275
- Rationale: core treats country as specificity, not competition — a `nil`-country fallback must
276
- not undercut a deliberate country-specific price.
277
- - **One `role_id` per price, modeled on `country_iso`** — nullable FK to `spree_roles`, `nil`
278
- meaning every customer including guests. No join table, no exclusion.
279
- Rationale: matches how country already works, removes a table and with it an N+1 preloading
280
- problem, and makes `role_id` a plain indexed column `with_prices` can filter in SQL.
281
- Cost: "everyone except role X" is no longer expressible.
282
- - **Role is eligibility only, not specificity.** A role match qualifies a customer to see a
283
- price; among everything visible, cheapest wins after country specificity.
284
- Rationale: chosen over "role beats country" and "country beats role". Consequence: a
285
- role-targeted price set above the untargeted price never applies.
286
- - **`price_type_id` and `role_id` are `desired_attributes`; `at` and `customer_role_ids` are
287
- not.** Rationale: core calls `prices.build(default_price_attributes)`, so every key in that
288
- hash must be an assignable `Spree::Price` column. The first two are columns; the contextual
289
- filters are not.
290
- - **Five price types ship seeded**: `default`, `wholesale`, `sale`, `clearance`, `employee`,
291
- seeded idempotently by `code`. Only `default` is structural and guarded; the rest are
292
- editable and discardable conveniences.
293
- Rationale: a store gets a usable vocabulary on install instead of an empty table, and the
294
- names document the intended use cases. Consequence recorded above: a type name conveys no
295
- access control — `employee` without `role_id` is visible to everyone.
296
- - **Customer roles are narrowed to pricing-relevant roles** before they reach the cache key.
297
- Rationale: selection is unaffected (a role no price references can never match), but cache
298
- cardinality drops from every role combination in the store to just combinations of roles
299
- actually used in pricing.
300
- - **`admin_notes` text column on `spree_prices`** for internal commentary ("Labor Day Sale 2025",
301
- "Overstock Sale of 2012"), never rendered to customers.
302
- Rationale: makes historical prices legible years after whoever created them left.
303
- - **solidus_admin scope is price types CRUD only.** Per-variant price management stays in the
304
- legacy backend.
305
- Rationale: the new admin has no price management at all — `Products::Show` exposes a single
306
- `f.text_field(:price)` writing through `DefaultPrice`, with no prices index, form or route.
307
- Building that screen is a larger project than this feature and would guess at a design
308
- upstream has not landed.
309
- - **The API passes explicit pricing options** rather than inheriting `current_pricing_options`.
310
- Rationale: core builds those `from_context`, which reads `current_spree_user`; on an
311
- admin-token request that would silently filter storefront prices by the admin's roles.
312
- - **Promotions were investigated and set aside.** `solidus_promotions` already ships
313
- `Benefits::AdvertisePrice`, price-level conditions (`PriceProduct` / `PriceTaxon` /
314
- `PriceOptionValue`), `Conditions::UserRole`, `PricePatch` (`discounts` / `discounted_amount`)
315
- and `ProductAdvertiser`. Not adopted because: a promotion *discounts* a price rather than
316
- replacing it (wrong semantics for B2B — it leaks retail and reports as promotional revenue);
317
- `Conditions::UserRole` implements only `order_eligible?` with `any`/`all` policies, so there
318
- is no price-level or exclude form; `ProductAdvertiser` is never invoked by Solidus and needs
319
- an order, which is awkward on catalog pages and for guests; and promotions have no concept of
320
- a price type at all. Revisit for time-boxed sale pricing, which the promotion engine does
321
- better.
322
-
323
- ### Superseded during the brainstorm
324
-
325
- Kept so the reasoning trail survives; do not implement these.
326
-
327
- - *Role rules in a join table with a `restrict` / `exclude` mode column.* Superseded by the
328
- single `role_id` column. Exclusion went with it.
329
- - *Contextual filters as tri-state, where `nil` means "ignore this filter".* Superseded once
330
- role became eligibility-only: `nil` and `[]` became indistinguishable for
331
- `customer_role_ids`, and an `at` of `nil` would have made an admin lookup pick arbitrarily
332
- between an expiring price and its replacement. Both now take plain defaults
333
- (`[]` and `Time.current`).
334
- - *A `priority` column on price types driving selection.* Superseded by cheapest-wins;
335
- `position` survives as a tie-breaker and admin sort order only.
336
-
337
- ## Context gathered
338
-
339
- - Local Solidus checkout: `4.8.0.dev` (`~/RubymineProjects/solidus`), minimum Rails 7.2.
340
- - Ruby 3.3.10, Bundler 4.0.16.
341
- - Relevant core seams:
342
- - `Spree::Price` — `core/app/models/spree/price.rb`
343
- - `Spree::Variant::PricingOptions` — `desired_attributes`, built `from_line_item`,
344
- `from_price`, `from_context`
345
- - `Spree::Variant::PriceSelector#price_for_options` — already pluggable
346
- - `Spree::Config.variant_price_selector_class`, which core delegates
347
- `pricing_options_class` from
348
- - `Spree::DefaultPrice` — `default_price` routes through the same selector, and
349
- `default_price_or_build` calls `prices.build(default_price_attributes)`
350
- - `Spree::Variant.with_prices` — checks only currency and country; needs overriding
351
- - `Spree::Role` / `Spree::RoleUser` — `has_many :users, through: :role_users`