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
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
<%= f.collection_select :price_type_id,
|
|
6
6
|
SolidusAdvancedPricing::PriceType.ordered,
|
|
7
7
|
:id, :name,
|
|
8
|
-
{},
|
|
9
|
-
{ class: 'custom-select fullwidth' } %>
|
|
8
|
+
{ include_blank: t('solidus_advanced_pricing.base_price') },
|
|
9
|
+
{ class: 'custom-select fullwidth', disabled: !f.object.new_record? } %>
|
|
10
|
+
<span class="field-hint"><%= t('solidus_advanced_pricing.price_type_hint') %></span>
|
|
10
11
|
<% end %>
|
|
11
12
|
</div>
|
|
12
13
|
|
|
@@ -25,14 +26,14 @@
|
|
|
25
26
|
<div class="col-2">
|
|
26
27
|
<%= f.field_container :valid_from do %>
|
|
27
28
|
<%= f.label :valid_from %>
|
|
28
|
-
<%= f.
|
|
29
|
+
<%= f.datetime_local_field :valid_from, class: 'fullwidth' %>
|
|
29
30
|
<% end %>
|
|
30
31
|
</div>
|
|
31
32
|
|
|
32
33
|
<div class="col-2">
|
|
33
34
|
<%= f.field_container :valid_to do %>
|
|
34
35
|
<%= f.label :valid_to %>
|
|
35
|
-
<%= f.
|
|
36
|
+
<%= f.datetime_local_field :valid_to, class: 'fullwidth' %>
|
|
36
37
|
<% end %>
|
|
37
38
|
</div>
|
|
38
39
|
|
|
@@ -10,12 +10,12 @@ Gem::Specification.new do |spec|
|
|
|
10
10
|
|
|
11
11
|
spec.summary = "Price types, validity windows and role targeting for Solidus prices"
|
|
12
12
|
spec.description = "Adds an admin-managed price type, an optional validity window, optional single-role targeting and internal admin notes to Spree::Price, selected through Solidus' own variant_price_selector_class seam."
|
|
13
|
-
spec.homepage = "https://github.com/
|
|
13
|
+
spec.homepage = "https://github.com/fcpeuro/solidus_advanced_pricing#readme"
|
|
14
14
|
spec.license = "BSD-3-Clause"
|
|
15
15
|
|
|
16
16
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
-
spec.metadata["source_code_uri"] = "https://github.com/
|
|
18
|
-
spec.metadata["changelog_uri"] = "https://github.com/
|
|
17
|
+
spec.metadata["source_code_uri"] = "https://github.com/fcpeuro/solidus_advanced_pricing"
|
|
18
|
+
spec.metadata["changelog_uri"] = "https://github.com/fcpeuro/solidus_advanced_pricing/blob/main/CHANGELOG.md"
|
|
19
19
|
|
|
20
20
|
spec.required_ruby_version = ">= 3.1"
|
|
21
21
|
|
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
|
|
@@ -178,9 +179,11 @@ files:
|
|
|
178
179
|
- db/migrate/20260915000001_create_solidus_advanced_pricing_price_types.rb
|
|
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
183
|
- lib/components/admin/solidus_admin/price_types/index/component.rb
|
|
183
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
|
|
184
187
|
- lib/controllers/api/spree/api/prices_controller.rb
|
|
185
188
|
- lib/controllers/backend/spree/admin/price_types_controller.rb
|
|
186
189
|
- lib/generators/solidus_advanced_pricing/install/install_generator.rb
|
|
@@ -191,6 +194,9 @@ files:
|
|
|
191
194
|
- lib/solidus_advanced_pricing/testing_support/factories.rb
|
|
192
195
|
- lib/solidus_advanced_pricing/testing_support/factories/price_type_factory.rb
|
|
193
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
|
|
194
200
|
- lib/views/api/spree/api/prices/_price.json.jbuilder
|
|
195
201
|
- lib/views/api/spree/api/prices/index.json.jbuilder
|
|
196
202
|
- lib/views/api/spree/api/prices/show.json.jbuilder
|
|
@@ -201,13 +207,13 @@ files:
|
|
|
201
207
|
- lib/views/backend/spree/admin/prices/_advanced_columns.html.erb
|
|
202
208
|
- lib/views/backend/spree/admin/prices/_advanced_fields.html.erb
|
|
203
209
|
- solidus_advanced_pricing.gemspec
|
|
204
|
-
homepage: https://github.com/
|
|
210
|
+
homepage: https://github.com/fcpeuro/solidus_advanced_pricing#readme
|
|
205
211
|
licenses:
|
|
206
212
|
- BSD-3-Clause
|
|
207
213
|
metadata:
|
|
208
|
-
homepage_uri: https://github.com/
|
|
209
|
-
source_code_uri: https://github.com/
|
|
210
|
-
changelog_uri: https://github.com/
|
|
214
|
+
homepage_uri: https://github.com/fcpeuro/solidus_advanced_pricing#readme
|
|
215
|
+
source_code_uri: https://github.com/fcpeuro/solidus_advanced_pricing
|
|
216
|
+
changelog_uri: https://github.com/fcpeuro/solidus_advanced_pricing/blob/main/CHANGELOG.md
|
|
211
217
|
rdoc_options: []
|
|
212
218
|
require_paths:
|
|
213
219
|
- lib
|
|
@@ -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`
|