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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +116 -1
- data/README.md +243 -16
- data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +33 -1
- data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +3 -1
- data/app/models/solidus_advanced_pricing/price_batch.rb +264 -0
- data/app/models/solidus_advanced_pricing/price_selector.rb +3 -1
- data/app/models/solidus_advanced_pricing/price_type.rb +2 -0
- data/app/models/solidus_advanced_pricing/price_type_cache.rb +19 -1
- data/config/locales/en.yml +11 -2
- data/config/routes.rb +8 -1
- data/db/migrate/20260923000001_add_role_to_solidus_advanced_pricing_price_types.rb +10 -0
- data/lib/components/admin/solidus_admin/price_types/index/component.rb +4 -0
- data/lib/controllers/admin/solidus_admin/price_types_controller.rb +1 -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/controllers/backend/spree/admin/price_types_controller.rb +1 -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
- data/lib/views/backend/spree/admin/price_types/_form.html.erb +11 -0
- data/lib/views/backend/spree/admin/price_types/index.html.erb +2 -0
- data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +5 -4
- data/solidus_advanced_pricing.gemspec +3 -3
- metadata +12 -6
- 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
|
@@ -1,3 +1,118 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
84
|
-
| `role_id` | integer, nullable | Belongs to `Spree::Role`. `nil` means
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
257
|
-
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
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|