solidus_advanced_pricing 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +116 -1
  4. data/README.md +243 -16
  5. data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +33 -1
  6. data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +3 -1
  7. data/app/models/solidus_advanced_pricing/price_batch.rb +264 -0
  8. data/app/models/solidus_advanced_pricing/price_selector.rb +3 -1
  9. data/app/models/solidus_advanced_pricing/price_type.rb +2 -0
  10. data/app/models/solidus_advanced_pricing/price_type_cache.rb +19 -1
  11. data/config/locales/en.yml +11 -2
  12. data/config/routes.rb +8 -1
  13. data/db/migrate/20260923000001_add_role_to_solidus_advanced_pricing_price_types.rb +10 -0
  14. data/lib/components/admin/solidus_admin/price_types/index/component.rb +4 -0
  15. data/lib/controllers/admin/solidus_admin/price_types_controller.rb +1 -1
  16. data/lib/controllers/api/spree/api/price_batches_controller.rb +47 -0
  17. data/lib/controllers/api/spree/api/price_types_controller.rb +17 -0
  18. data/lib/controllers/api/spree/api/prices_controller.rb +79 -1
  19. data/lib/controllers/backend/spree/admin/price_types_controller.rb +1 -1
  20. data/lib/solidus_advanced_pricing/configuration.rb +14 -3
  21. data/lib/solidus_advanced_pricing/version.rb +1 -1
  22. data/lib/views/api/spree/api/price_batches/create.json.jbuilder +6 -0
  23. data/lib/views/api/spree/api/price_types/_price_type.json.jbuilder +1 -0
  24. data/lib/views/api/spree/api/price_types/index.json.jbuilder +3 -0
  25. data/lib/views/backend/spree/admin/price_types/_form.html.erb +11 -0
  26. data/lib/views/backend/spree/admin/price_types/index.html.erb +2 -0
  27. data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +5 -4
  28. data/solidus_advanced_pricing.gemspec +3 -3
  29. metadata +12 -6
  30. data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +0 -351
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3dd5e3bb515d712553025000029ebf739470339c09c0a615b70084a134ceff3c
4
- data.tar.gz: 065ce411b91483ac3e6b3ef13f09144d5015c986cc27fbbff9c1544bf4978355
3
+ metadata.gz: 0feded43570200af31d61d2822614677e232d696b480196576c0651edafee278
4
+ data.tar.gz: 20b1184e197fc387a095d5af68b7936fc894ccd213b6386a259bff8f0e77e6e5
5
5
  SHA512:
6
- metadata.gz: 740e98329afaa1a757bde107a9ea231d22b4a3c92ae0840b0a3ea9462b0d6227b3d93acfc05a4c27a43ec285b4e0fc02b99056f3352950e6047ac7919a975c56
7
- data.tar.gz: e4426d9b9fac0bda2db9a87f228f424ced485a16003c5ec220dfa845756cdf87c637605f2c8e6fe0f4802149b9f16f6207efab762b43db73e0510f2ab25bea13
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
@@ -1,3 +1,118 @@
1
1
  # Changelog
2
2
 
3
- See https://github.com/solidusio-contrib/solidus_advanced_pricing/releases or OLD_CHANGELOG.md for older versions.
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
56
+ ## [0.2.0] - 2026-09-23
57
+
58
+ ### Added
59
+
60
+ - Price types can carry a **default role**. A price with no role of its own inherits its
61
+ type's role, so a type like `employee` can be targeted once instead of on every price.
62
+ A price's own role still wins when set. This is the recommended way to avoid publishing
63
+ a role-named price to everyone.
64
+ - `map` and `promotional` price types are now seeded, bringing the set to six.
65
+
66
+ ### Fixed
67
+
68
+ - **An untyped price could not be created in the admin.** The price type select had no
69
+ blank option, and defaulted to its first entry — so every price created through the UI
70
+ was silently typed `wholesale`. A regression from making `price_type_id` nullable in
71
+ 0.1.0.
72
+ - **Validity windows lost their time.** `valid_from` and `valid_to` are datetime columns,
73
+ but the form used Solidus' `.datepicker` class, which is initialised as flatpickr
74
+ without `enableTime` and can only produce a date. Any time an admin set was dropped to
75
+ midnight. Now rendered as native `datetime-local` inputs.
76
+ - Deleting a `Spree::Role` referenced by a price type's default role raised a raw foreign
77
+ key violation instead of being blocked with an error.
78
+
79
+ ### Changed
80
+
81
+ - **The price type is now locked once a price exists**, matching how core treats the
82
+ country field. Changing which pricing dimension an existing row belongs to reinterprets
83
+ historical data rather than correcting it.
84
+
85
+ ## [0.1.0] - 2026-09-23
86
+
87
+ Initial release.
88
+
89
+ ### Added
90
+
91
+ - **Price types** — an admin-managed model with a code, name and position. `price_type_id`
92
+ on `spree_prices` is nullable; `NULL` means the untyped base price.
93
+ - **Validity windows** — optional `valid_from` / `valid_to` on each price. `valid_to` is
94
+ exclusive.
95
+ - **Role targeting** — an optional `Spree::Role` per price. Blank means every customer,
96
+ guests included. Role is *eligibility only*, never specificity: a role-targeted price set
97
+ above the untargeted price never applies.
98
+ - **`admin_notes`** — internal commentary on a price, never rendered to customers.
99
+ - A `PriceSelector` and `PricingOptions` registered through
100
+ `Spree::Config.variant_price_selector_class`: filter by currency, type, window and role;
101
+ prefer the country-specific bucket over the any-country fallback; cheapest wins.
102
+ - `Spree::Variant#price_of_type` and `#base_price` for compare-at (strikethrough) display.
103
+ - `Spree::Variant.with_prices` now honours validity windows and role targeting, so a
104
+ variant whose only price has expired no longer counts as purchasable.
105
+ - Full legacy backend admin: price fields, table columns, a price types CRUD, and a
106
+ settings sidebar link. A price types index for `solidus_admin`.
107
+ - `GET /api/variants/:variant_id/prices` — admin-facing; `admin_notes` is exposed only to
108
+ a user who can update the price.
109
+
110
+ ### Security
111
+
112
+ - Pricing cache keys include the customer's pricing-relevant roles and a coarse time
113
+ bucket. Without the role component a role-targeted price would be cached and served to
114
+ guests.
115
+
116
+ [0.3.0]: https://github.com/fcpeuro/solidus_advanced_pricing/compare/v0.2.0...v0.3.0
117
+ [0.2.0]: https://github.com/fcpeuro/solidus_advanced_pricing/compare/v0.1.0...v0.2.0
118
+ [0.1.0]: https://github.com/fcpeuro/solidus_advanced_pricing/releases/tag/v0.1.0
data/README.md CHANGED
@@ -80,8 +80,8 @@ value for `price_of_type`.
80
80
 
81
81
  | Column | Type | Meaning |
82
82
  |---|---|---|
83
- | `price_type_id` | bigint, nullable | Belongs to `SolidusAdvancedPricing::PriceType`. `nil` means the untyped base price — exactly as `role_id: nil` means every customer. |
84
- | `role_id` | integer, nullable | Belongs to `Spree::Role`. `nil` means visible to everyone, including guests. |
83
+ | `price_type_id` | bigint, nullable | Belongs to `SolidusAdvancedPricing::PriceType`. `nil` means the untyped base price — exactly as `role_id: nil` means "inherit from the type" (see below). |
84
+ | `role_id` | integer, nullable | Belongs to `Spree::Role`. `nil` does **not** mean "public" — it means the price's visibility is inherited from its price type, if any. See [Effective role](#effective-role) below. |
85
85
  | `valid_from` | datetime, nullable | Window opens here. `nil` means always open. |
86
86
  | `valid_to` | datetime, nullable | Window closes here, **exclusive** — a price is valid up to but not including this instant, so a window ending at midnight and the next one starting at midnight don't both match. `nil` means never closes. |
87
87
  | `admin_notes` | text, nullable | Internal only. Never shown to customers; see [API](#api) for exactly who can see it. |
@@ -89,15 +89,61 @@ value for `price_of_type`.
89
89
  `price_type_id`, `role_id`, `valid_from` and `valid_to` are all optional. `valid_to`
90
90
  must be after `valid_from` when both are set.
91
91
 
92
- ## A price type is not access control
92
+ `SolidusAdvancedPricing::PriceType` also carries a nullable `role_id` of its own —
93
+ the type's *default* role. See [Effective role](#effective-role).
93
94
 
94
- **Setting a price's `price_type` to `employee` does nothing on its own.** Type and
95
- role are independent columns. A price typed `employee` with `role_id` left blank is
96
- visible to every customer, guests included — the name is just a label for the admin
97
- UI and reporting; it grants no eligibility by itself. Two of the six seeded types,
98
- `wholesale` and `employee`, exist specifically to invite this mistake.
95
+ ## Effective role
99
96
 
100
- If a price should only be available to a specific group, you must **also** set `role`:
97
+ A price's visibility to a customer is never decided by its own `role_id` alone. It is
98
+ decided by its **effective role**:
99
+
100
+ ```ruby
101
+ price.role_id || price.price_type&.role_id
102
+ ```
103
+
104
+ **`nil` on a price's own `role_id` means "inherit from the type," not "explicitly
105
+ public."** If the price has no type, or its type has no default role, the effective
106
+ role is `nil` and the price is visible to everyone, including guests — exactly as
107
+ before this feature existed. But once a price type carries a default role, every
108
+ price of that type inherits it unless the price sets its own `role_id`.
109
+
110
+ The deliberate consequence: **if the `employee` type has a default role, you cannot
111
+ make an employee-typed price public.** Its `role_id` is either blank (inherits the
112
+ type's role) or set to some other role (its own, more specific, targeting) — there is
113
+ no value that means "ignore the type's default and show this to everyone." That
114
+ tradeoff is the point. It turns this gem's biggest footgun — a price typed `employee`
115
+ with no role, visible to every customer including guests — into something a store
116
+ fixes once, on the type, instead of something every price author must remember.
117
+
118
+ Stores are **not** seeded with any type-level defaults; all six seeded types
119
+ (`wholesale`, `sale`, `clearance`, `employee`, `map`, `promotional`) ship with
120
+ `role_id: nil`, because the correct role for, say, `employee` doesn't exist in every
121
+ store. Set it yourself once eligibility should be automatic:
122
+
123
+ ```ruby
124
+ employee_role = Spree::Role.find_or_create_by!(name: "employee")
125
+ employee_type = SolidusAdvancedPricing::PriceType.find_by(code: "employee")
126
+ employee_type.update!(role: employee_role)
127
+
128
+ # Every price typed `employee`, existing or future, is now visible only to
129
+ # customers holding the `employee` role -- no per-price role_id required.
130
+ variant.prices.create!(amount: 45, currency: "USD", price_type: employee_type)
131
+ ```
132
+
133
+ ## A price type is not access control by default
134
+
135
+ **Setting a price's `price_type` to `employee` does nothing on its own unless the
136
+ type itself carries a default role.** Type and role are independent columns until a
137
+ store links them via the type's `role_id`. A price typed `employee`, with the type
138
+ having no default role and the price's own `role_id` left blank, is visible to every
139
+ customer, guests included — the type name alone is just a label for the admin UI and
140
+ reporting; it grants no eligibility by itself. Two of the six seeded types,
141
+ `wholesale` and `employee`, exist specifically to invite this mistake — and setting a
142
+ default role on the type (above) is the recommended way to close it for good, rather
143
+ than remembering to set `role_id` on every price of that type.
144
+
145
+ If you'd rather target one specific price without touching the type, set `role`
146
+ directly on it:
101
147
 
102
148
  ```ruby
103
149
  employee_role = Spree::Role.find_or_create_by!(name: "employee")
@@ -112,7 +158,9 @@ variant.prices.create!(
112
158
  ```
113
159
 
114
160
  With both fields set, a guest or any customer without the `employee` role still sees
115
- the ordinary price; only a customer holding that role sees $45.
161
+ the ordinary price; only a customer holding that role sees $45. This still works
162
+ exactly as before even once the type has its own default role — an explicit
163
+ `role_id` on the price always wins over the type's.
116
164
 
117
165
  ## How a price is chosen
118
166
 
@@ -121,9 +169,10 @@ in three steps:
121
169
 
122
170
  1. **Filter.** A price survives only if: its currency matches exactly; the current
123
171
  time falls inside its validity window (or it has none); and it's visible to the
124
- customer — `role_id` is blank, or the customer holds that role. If a price type is
125
- pinned (see [Admin vs. customer lookups](#admin-vs-customer-lookups) below),
126
- non-matching types are dropped here too.
172
+ customer — its [effective role](#effective-role) is blank, or the customer holds
173
+ that role. If a price type is pinned (see
174
+ [Admin vs. customer lookups](#admin-vs-customer-lookups) below), non-matching
175
+ types are dropped here too.
127
176
  2. **Country specificity.** Among what survives, a price matching the customer's
128
177
  country beats every country-agnostic (`country_iso: nil`) price — **even when the
129
178
  agnostic price is cheaper.** This matches core Solidus behavior.
@@ -253,15 +302,20 @@ against pre-1.0 `solidus_admin` internals (`SolidusAdmin::UI::Pages::Index::Comp
253
302
  ## API
254
303
 
255
304
  ```
256
- GET /api/variants/:variant_id/prices
257
- 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
258
312
  ```
259
313
 
260
314
  Each price is serialized with its advanced attributes — `price_type_code`,
261
315
  `price_type_name`, `role_id`, `valid_from`, `valid_to` — and `admin_notes` is included
262
316
  only for a caller who can update that price.
263
317
 
264
- **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
265
319
  non-admin API token gets) grants no rights on `Spree::Price` at all, so a non-admin
266
320
  token receives `401 Unauthorized` on the whole endpoint, not a filtered response.
267
321
  Headless storefronts do not need this endpoint for normal pricing: core's variants
@@ -269,6 +323,179 @@ endpoint already serializes `price` and `display_price` through this gem's selec
269
323
  automatically, because the selector is registered globally via
270
324
  `Spree::Config.variant_price_selector_class`.
271
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
+
272
499
  ## Storefront: compare-at (strikethrough) pricing
273
500
 
274
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) {
@@ -30,8 +31,23 @@ module SolidusAdvancedPricing
30
31
  .where(arel_table[:valid_to].eq(nil).or(arel_table[:valid_to].gt(time)))
31
32
  }
32
33
 
34
+ # Tests the *effective* role (price's own role_id, falling back to its
35
+ # type's) via COALESCE over a LEFT JOIN, not just the price's column —
36
+ # a price whose type carries a default role must be excluded from a
37
+ # guest's listing even though the price row itself has role_id: nil.
38
+ # left_joins(:price_type) does not apply PriceType's `kept` default
39
+ # scope (verified empirically), so a retired type's default role still
40
+ # applies to its historical prices.
33
41
  base.scope :visible_to_roles, ->(role_ids) {
34
- where(role_id: [nil, *role_ids])
42
+ price_types_table = SolidusAdvancedPricing::PriceType.arel_table
43
+ effective_role_id = Arel::Nodes::NamedFunction.new(
44
+ "COALESCE", [arel_table[:role_id], price_types_table[:role_id]]
45
+ )
46
+
47
+ condition = effective_role_id.eq(nil)
48
+ condition = condition.or(effective_role_id.in(role_ids)) if role_ids.present?
49
+
50
+ left_joins(:price_type).where(condition)
35
51
  }
36
52
 
37
53
  base.scope :for_price_type, ->(price_type) {
@@ -41,8 +57,24 @@ module SolidusAdvancedPricing
41
57
  base.after_commit { SolidusAdvancedPricing::PriceTypeCache.clear }
42
58
  end
43
59
 
60
+ # nil on the price means "inherit from the type", not "public".
61
+ def effective_role_id
62
+ role_id || SolidusAdvancedPricing::PriceTypeCache.role_id_for(price_type_id)
63
+ end
64
+
44
65
  private
45
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
+
46
78
  def valid_to_after_valid_from
47
79
  return if valid_from.blank? || valid_to.blank?
48
80
  return if valid_to > valid_from
@@ -12,7 +12,9 @@ module SolidusAdvancedPricing
12
12
  private
13
13
 
14
14
  def prevent_destroying_referenced_role
15
- return unless ::Spree::Price.with_discarded.exists?(role_id: id)
15
+ referenced = ::Spree::Price.with_discarded.exists?(role_id: id) ||
16
+ SolidusAdvancedPricing::PriceType.with_discarded.exists?(role_id: id)
17
+ return unless referenced
16
18
 
17
19
  errors.add(:base, :referenced_by_prices)
18
20
  throw :abort