solidus_advanced_pricing 0.1.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 +7 -0
- data/.circleci/config.yml +53 -0
- data/.gem_release.yml +5 -0
- data/.github/stale.yml +1 -0
- data/.github/workflows/lint.yml +25 -0
- data/.github/workflows/test.yml +86 -0
- data/.github_changelog_generator +2 -0
- data/.gitignore +21 -0
- data/.rspec +2 -0
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +64 -0
- data/LICENSE +26 -0
- data/README.md +377 -0
- data/Rakefile +41 -0
- data/app/assets/javascripts/spree/backend/solidus_advanced_pricing.js +2 -0
- data/app/assets/javascripts/spree/frontend/solidus_advanced_pricing.js +2 -0
- data/app/assets/stylesheets/spree/backend/solidus_advanced_pricing.css +4 -0
- data/app/assets/stylesheets/spree/frontend/solidus_advanced_pricing.css +4 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/app_configuration_decorator.rb +17 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/permitted_attributes_decorator.rb +27 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +56 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +24 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/variant_decorator.rb +52 -0
- data/app/models/solidus_advanced_pricing/price_selector.rb +73 -0
- data/app/models/solidus_advanced_pricing/price_type.rb +80 -0
- data/app/models/solidus_advanced_pricing/price_type_cache.rb +45 -0
- data/app/models/solidus_advanced_pricing/pricing_options.rb +107 -0
- data/app/overrides/spree/admin/prices/_form/add_advanced_fields.html.erb.deface +2 -0
- data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_columns.html.erb.deface +2 -0
- data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_headers.html.erb.deface +4 -0
- data/app/overrides/spree/admin/prices/_table/add_advanced_columns.html.erb.deface +2 -0
- data/app/overrides/spree/admin/prices/_table/add_advanced_headers.html.erb.deface +4 -0
- data/app/overrides/spree/admin/shared/_configuration_menu/add_price_types_link.html.erb.deface +4 -0
- data/bin/console +17 -0
- data/bin/rails +7 -0
- data/bin/rails-engine +13 -0
- data/bin/rails-sandbox +16 -0
- data/bin/rake +7 -0
- data/bin/sandbox +85 -0
- data/bin/setup +8 -0
- data/config/locales/en.yml +44 -0
- data/config/routes.rb +34 -0
- data/db/migrate/20260915000001_create_solidus_advanced_pricing_price_types.rb +16 -0
- data/db/migrate/20260915000002_seed_solidus_advanced_pricing_price_types.rb +34 -0
- data/db/migrate/20260915000003_add_advanced_pricing_to_spree_prices.rb +21 -0
- data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +351 -0
- data/lib/components/admin/solidus_admin/price_types/index/component.rb +65 -0
- data/lib/controllers/admin/solidus_admin/price_types_controller.rb +26 -0
- data/lib/controllers/api/spree/api/prices_controller.rb +32 -0
- data/lib/controllers/backend/spree/admin/price_types_controller.rb +40 -0
- data/lib/generators/solidus_advanced_pricing/install/install_generator.rb +33 -0
- data/lib/generators/solidus_advanced_pricing/install/templates/initializer.rb +7 -0
- data/lib/solidus_advanced_pricing/configuration.rb +21 -0
- data/lib/solidus_advanced_pricing/engine.rb +41 -0
- data/lib/solidus_advanced_pricing/testing_support/factories/price_type_factory.rb +13 -0
- data/lib/solidus_advanced_pricing/testing_support/factories.rb +4 -0
- data/lib/solidus_advanced_pricing/version.rb +5 -0
- data/lib/solidus_advanced_pricing.rb +19 -0
- data/lib/views/api/spree/api/prices/_price.json.jbuilder +8 -0
- data/lib/views/api/spree/api/prices/index.json.jbuilder +3 -0
- data/lib/views/api/spree/api/prices/show.json.jbuilder +1 -0
- data/lib/views/backend/spree/admin/price_types/_form.html.erb +20 -0
- data/lib/views/backend/spree/admin/price_types/edit.html.erb +9 -0
- data/lib/views/backend/spree/admin/price_types/index.html.erb +31 -0
- data/lib/views/backend/spree/admin/price_types/new.html.erb +9 -0
- data/lib/views/backend/spree/admin/prices/_advanced_columns.html.erb +7 -0
- data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +46 -0
- data/solidus_advanced_pricing.gemspec +39 -0
- metadata +228 -0
|
@@ -0,0 +1,351 @@
|
|
|
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`
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdmin
|
|
4
|
+
module PriceTypes
|
|
5
|
+
module Index
|
|
6
|
+
# Read-only listing. Create/edit/destroy for SolidusAdvancedPricing::PriceType
|
|
7
|
+
# stay on the legacy backend (spree.*_admin_price_type_path) -- mirrors how
|
|
8
|
+
# adjustment_reasons' index links back to the legacy backend rather than a
|
|
9
|
+
# new-admin form.
|
|
10
|
+
class Component < ::SolidusAdmin::UI::Pages::Index::Component
|
|
11
|
+
def model_class = ::SolidusAdvancedPricing::PriceType
|
|
12
|
+
|
|
13
|
+
def search_key = :name_or_code_cont
|
|
14
|
+
|
|
15
|
+
def search_url = solidus_admin.price_types_path
|
|
16
|
+
|
|
17
|
+
def edit_path(price_type)
|
|
18
|
+
spree.edit_admin_price_type_path(price_type, **search_filter_params)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def page_actions
|
|
22
|
+
render component("ui/button").new(
|
|
23
|
+
tag: :a,
|
|
24
|
+
text: t(".add"),
|
|
25
|
+
href: spree.new_admin_price_type_path,
|
|
26
|
+
icon: "add-line",
|
|
27
|
+
class: "align-self-end w-full"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def batch_actions
|
|
32
|
+
[
|
|
33
|
+
{
|
|
34
|
+
label: t(".batch_actions.delete"),
|
|
35
|
+
action: solidus_admin.price_types_path(**search_filter_params),
|
|
36
|
+
method: :delete,
|
|
37
|
+
icon: "delete-bin-7-line"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def columns
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
header: :name,
|
|
46
|
+
data: ->(price_type) do
|
|
47
|
+
link_to price_type.name, edit_path(price_type), class: "body-link"
|
|
48
|
+
end
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
header: :code,
|
|
52
|
+
data: ->(price_type) do
|
|
53
|
+
link_to price_type.code, edit_path(price_type), class: "body-link"
|
|
54
|
+
end
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
header: :position,
|
|
58
|
+
data: ->(price_type) { price_type.position }
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdmin
|
|
4
|
+
class PriceTypesController < SolidusAdmin::ResourcesController
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def resource_class = SolidusAdvancedPricing::PriceType
|
|
8
|
+
|
|
9
|
+
def permitted_resource_params
|
|
10
|
+
params.require(:price_type).permit(:name, :code, :position)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# ResourcesController derives these from resource_class.model_name, which for
|
|
14
|
+
# a namespaced model produces "solidus_advanced_pricing_price_type(s)" instead
|
|
15
|
+
# of the "price_type(s)" the admin_resources :price_types route actually
|
|
16
|
+
# declares -- same mismatch already worked around in the legacy backend
|
|
17
|
+
# controller.
|
|
18
|
+
def resource_name = "price_type"
|
|
19
|
+
|
|
20
|
+
def plural_resource_name = "price_types"
|
|
21
|
+
|
|
22
|
+
# Authorization::authorization_subject infers "Spree::#{controller_name.classify}",
|
|
23
|
+
# which doesn't exist for our namespaced model either.
|
|
24
|
+
def authorization_subject = SolidusAdvancedPricing::PriceType
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Api
|
|
5
|
+
class PricesController < Spree::Api::BaseController
|
|
6
|
+
def index
|
|
7
|
+
authorize! :index, Spree::Price
|
|
8
|
+
@prices = scope.order(:id)
|
|
9
|
+
respond_with(@prices)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@price = scope.find(params[:id])
|
|
14
|
+
authorize! :show, @price
|
|
15
|
+
respond_with(@price)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Lists what exists for this variant; never routed through
|
|
21
|
+
# current_pricing_options, which would filter by the requesting admin's
|
|
22
|
+
# own roles via current_spree_user.
|
|
23
|
+
def scope
|
|
24
|
+
variant.prices.accessible_by(current_ability, :index)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def variant
|
|
28
|
+
@variant ||= Spree::Variant.find(params[:variant_id])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Admin
|
|
5
|
+
class PriceTypesController < ResourceController
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def model_class
|
|
9
|
+
SolidusAdvancedPricing::PriceType
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def collection
|
|
13
|
+
@collection ||= model_class.ordered
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def permitted_resource_params
|
|
17
|
+
params.require(:price_type).permit(:name, :code, :position)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ResourceController derives these from model_class.model_name, which for a
|
|
21
|
+
# namespaced model produces route names (e.g. admin_solidus_advanced_pricing_price_types)
|
|
22
|
+
# that don't match the admin_price_types routes actually declared.
|
|
23
|
+
def new_object_url(options = {})
|
|
24
|
+
spree.new_admin_price_type_url(options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def edit_object_url(object, options = {})
|
|
28
|
+
spree.edit_admin_price_type_url(object, options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def object_url(object = @object, options = {})
|
|
32
|
+
spree.admin_price_type_url(object, options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def collection_url(options = {})
|
|
36
|
+
spree.admin_price_types_url(options)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module SolidusAdvancedPricing
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
class_option :auto_run_migrations, type: :boolean, default: false
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "initializer.rb", "config/initializers/solidus_advanced_pricing.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_migrations
|
|
17
|
+
run "bin/rails railties:install:migrations FROM=solidus_advanced_pricing"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run_migrations
|
|
21
|
+
run_migrations = options[:auto_run_migrations] || ask(
|
|
22
|
+
"Would you like to run the migrations now? [Y/n]"
|
|
23
|
+
).in?(["", "y", "Y"])
|
|
24
|
+
|
|
25
|
+
if run_migrations
|
|
26
|
+
run "bin/rails db:migrate"
|
|
27
|
+
else
|
|
28
|
+
puts "Skipping bin/rails db:migrate, don't forget to run it!" # rubocop:disable Rails/Output
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Spree::Config.variant_price_selector_class = "SolidusAdvancedPricing::PriceSelector"
|
|
4
|
+
|
|
5
|
+
# Seconds of granularity for the time component of pricing cache keys.
|
|
6
|
+
# Set to 0 to omit time from the key entirely.
|
|
7
|
+
# Spree::Config.advanced_pricing_cache_granularity = 60
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
class Configuration
|
|
5
|
+
# Define here the settings for this extension, e.g.:
|
|
6
|
+
#
|
|
7
|
+
# attr_accessor :my_setting
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
alias_method :config, :configuration
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "solidus_core"
|
|
4
|
+
require "solidus_support"
|
|
5
|
+
# Not auto-required by Bundler: `gemspec` in the Gemfile folds runtime deps
|
|
6
|
+
# into a single pseudo-dependency, so Deface's ActionView hook never loads
|
|
7
|
+
# unless something requires it explicitly.
|
|
8
|
+
require "deface"
|
|
9
|
+
|
|
10
|
+
module SolidusAdvancedPricing
|
|
11
|
+
class Engine < Rails::Engine
|
|
12
|
+
include SolidusSupport::EngineExtensions
|
|
13
|
+
|
|
14
|
+
isolate_namespace ::Spree
|
|
15
|
+
|
|
16
|
+
engine_name "solidus_advanced_pricing"
|
|
17
|
+
|
|
18
|
+
# use rspec for tests
|
|
19
|
+
config.generators do |g|
|
|
20
|
+
g.test_framework :rspec
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A decorator would re-append this on every `to_prepare` reload; an
|
|
24
|
+
# initializer runs once at boot.
|
|
25
|
+
initializer "solidus_advanced_pricing.menu_items" do
|
|
26
|
+
# Version check, not a constant check: at initializer time solidus_admin's
|
|
27
|
+
# Zeitwerk autoloads are not set up yet. Our route needs
|
|
28
|
+
# SolidusAdmin::ResourcesController, added in Solidus 4.5.
|
|
29
|
+
next unless defined?(::SolidusAdmin::Config)
|
|
30
|
+
next if ::Spree.solidus_gem_version < ::Gem::Version.new("4.5")
|
|
31
|
+
next if ::SolidusAdmin::Config.menu_items.any? { |item| item[:key].to_s == "price_types" }
|
|
32
|
+
|
|
33
|
+
::SolidusAdmin::Config.menu_items << {
|
|
34
|
+
key: "price_types",
|
|
35
|
+
route: -> { solidus_admin.price_types_path },
|
|
36
|
+
icon: "price-tag-3-line",
|
|
37
|
+
position: 65
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FactoryBot.define do
|
|
4
|
+
factory :price_type, class: "SolidusAdvancedPricing::PriceType" do
|
|
5
|
+
sequence(:name) { |n| "Price Type #{n}" }
|
|
6
|
+
sequence(:code) { |n| "price_type_#{n}" }
|
|
7
|
+
position { 100 }
|
|
8
|
+
|
|
9
|
+
trait :discarded do
|
|
10
|
+
deleted_at { Time.current }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "solidus_advanced_pricing/configuration"
|
|
4
|
+
require "solidus_advanced_pricing/version"
|
|
5
|
+
require "solidus_advanced_pricing/engine"
|
|
6
|
+
|
|
7
|
+
module SolidusAdvancedPricing
|
|
8
|
+
# nil stays nil (the base price); a PriceType or id passes through; a code resolves.
|
|
9
|
+
def self.resolve_price_type_id(price_type)
|
|
10
|
+
case price_type
|
|
11
|
+
when nil then nil
|
|
12
|
+
when ::SolidusAdvancedPricing::PriceType then price_type.id
|
|
13
|
+
when Integer then price_type
|
|
14
|
+
else
|
|
15
|
+
SolidusAdvancedPricing::PriceTypeCache.id_for(price_type) ||
|
|
16
|
+
raise(ArgumentError, "no price type with code #{price_type.inspect}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|