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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +50 -0
- data/README.md +181 -3
- data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +12 -0
- data/app/models/solidus_advanced_pricing/price_batch.rb +264 -0
- data/config/locales/en.yml +4 -0
- data/config/routes.rb +8 -1
- data/lib/controllers/api/spree/api/price_batches_controller.rb +47 -0
- data/lib/controllers/api/spree/api/price_types_controller.rb +17 -0
- data/lib/controllers/api/spree/api/prices_controller.rb +79 -1
- data/lib/solidus_advanced_pricing/configuration.rb +14 -3
- data/lib/solidus_advanced_pricing/version.rb +1 -1
- data/lib/views/api/spree/api/price_batches/create.json.jbuilder +6 -0
- data/lib/views/api/spree/api/price_types/_price_type.json.jbuilder +1 -0
- data/lib/views/api/spree/api/price_types/index.json.jbuilder +3 -0
- metadata +7 -2
- data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +0 -351
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0feded43570200af31d61d2822614677e232d696b480196576c0651edafee278
|
|
4
|
+
data.tar.gz: 20b1184e197fc387a095d5af68b7936fc894ccd213b6386a259bff8f0e77e6e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2a90ff27650243c41246a0dfa7b9cd1b6a672c73060dcf6abcaf007f101baefdf3d627c28301ac06e576b1a7bb1a4f9602135935a6cbe2327ea62918fa20d20
|
|
7
|
+
data.tar.gz: 5b57c0ee2ef362b15e377db52ebd80c1624db43c3c94d69846e8e93bfacb7ce17864766f35cb9ee56c8113a424b3afb5a6d550be20b18872eefa6f8bd04ef595
|
data/.gitignore
CHANGED
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
|
|
306
|
-
GET
|
|
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
|
-
**
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
json.call(price_type, :id, :code, :name, :position, :role_id)
|
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.
|
|
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`
|