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
data/README.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Solidus Advanced Pricing
|
|
2
|
+
|
|
3
|
+
[](https://circleci.com/gh/solidusio-contrib/solidus_advanced_pricing)
|
|
4
|
+
[](https://codecov.io/gh/solidusio-contrib/solidus_advanced_pricing)
|
|
5
|
+
|
|
6
|
+
Adds price types, role-targeting, validity windows and admin notes to `Spree::Price`,
|
|
7
|
+
so a variant can carry several competing prices — a wholesale tier, a scheduled sale,
|
|
8
|
+
an employee price — and have the right one selected automatically.
|
|
9
|
+
|
|
10
|
+
This gem is for prices that *are* a different amount. See
|
|
11
|
+
[Relationship to solidus_promotions](#relationship-to-solidus_promotions) below if what
|
|
12
|
+
you actually want is a discount.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Solidus **4.5** or newer (`< 5`)
|
|
17
|
+
- Rails 7.0–7.2
|
|
18
|
+
- Ruby 3.1 or newer
|
|
19
|
+
|
|
20
|
+
Tested on PostgreSQL, MySQL and SQLite.
|
|
21
|
+
|
|
22
|
+
### Backporting to Solidus 4.0–4.4
|
|
23
|
+
|
|
24
|
+
The 4.5 floor exists for one reason: the solidus_admin price types screen inherits from
|
|
25
|
+
`SolidusAdmin::ResourcesController`, which was added in Solidus 4.5. Everything else —
|
|
26
|
+
the model layer, price selection, the legacy backend, and the API — works from Solidus
|
|
27
|
+
4.0.
|
|
28
|
+
|
|
29
|
+
The seams for a backport are deliberately still in place:
|
|
30
|
+
|
|
31
|
+
- `config/routes.rb` and `lib/solidus_advanced_pricing/engine.rb` each guard the
|
|
32
|
+
solidus_admin route and menu entry on `Spree.solidus_gem_version >= "4.5"`, so both
|
|
33
|
+
disable themselves on older versions rather than raising.
|
|
34
|
+
- The `Gemfile` only installs `solidus_admin` when `SOLIDUS_BRANCH` is `main` or `v4.5+`.
|
|
35
|
+
- `spec/features/admin/solidus_admin_price_types_spec.rb` and
|
|
36
|
+
`spec/lib/solidus_advanced_pricing/engine_spec.rb` skip when
|
|
37
|
+
`SolidusAdmin::ResourcesController` is undefined.
|
|
38
|
+
|
|
39
|
+
So a backport is: lower the floor in the gemspec, add the older versions back to the CI
|
|
40
|
+
matrix in `.github/workflows/test.yml`, and the solidus_admin half stays dormant below
|
|
41
|
+
4.5 on its own. It was green on 4.1–4.4 that way before the floor was raised.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Add solidus_advanced_pricing to your Gemfile:
|
|
46
|
+
|
|
47
|
+
```shell
|
|
48
|
+
bundle add solidus_advanced_pricing
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then run the installation generator:
|
|
52
|
+
|
|
53
|
+
```shell
|
|
54
|
+
bin/rails generate solidus_advanced_pricing:install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The generator:
|
|
58
|
+
|
|
59
|
+
- Copies `config/initializers/solidus_advanced_pricing.rb`, which registers
|
|
60
|
+
`SolidusAdvancedPricing::PriceSelector` as `Spree::Config.variant_price_selector_class`.
|
|
61
|
+
This is what makes pricing type/role/validity-aware; without it the gem's columns exist
|
|
62
|
+
but are never consulted.
|
|
63
|
+
- Copies this gem's three migrations into your app.
|
|
64
|
+
- Offers to run `bin/rails db:migrate` for you.
|
|
65
|
+
|
|
66
|
+
Those migrations add the columns described below to `spree_prices` and seed six
|
|
67
|
+
price types (`wholesale`, `sale`, `clearance`, `employee`, `map`, `promotional`). Nothing about existing
|
|
68
|
+
pricing behavior changes until you start setting the new columns — see
|
|
69
|
+
[Backward compatibility](#backward-compatibility).
|
|
70
|
+
|
|
71
|
+
`map` (minimum advertised price) and MSRP are easy to confuse but behave oppositely
|
|
72
|
+
under cheapest-wins. MAP is a price you are *required* to sell at, so it is a real
|
|
73
|
+
selling price and will be selected — a MAP-restricted variant often has no untyped
|
|
74
|
+
base price at all. MSRP (not seeded here; add your own type if you want one) is a
|
|
75
|
+
suggested price items are typically never sold at; because it sits *above* the
|
|
76
|
+
selling price, cheapest-wins never selects it, which makes it a natural compare-at
|
|
77
|
+
value for `price_of_type`.
|
|
78
|
+
|
|
79
|
+
## What it adds to `Spree::Price`
|
|
80
|
+
|
|
81
|
+
| Column | Type | Meaning |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `price_type_id` | bigint, nullable | Belongs to `SolidusAdvancedPricing::PriceType`. `nil` means the untyped base price — exactly as `role_id: nil` means every customer. |
|
|
84
|
+
| `role_id` | integer, nullable | Belongs to `Spree::Role`. `nil` means visible to everyone, including guests. |
|
|
85
|
+
| `valid_from` | datetime, nullable | Window opens here. `nil` means always open. |
|
|
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
|
+
| `admin_notes` | text, nullable | Internal only. Never shown to customers; see [API](#api) for exactly who can see it. |
|
|
88
|
+
|
|
89
|
+
`price_type_id`, `role_id`, `valid_from` and `valid_to` are all optional. `valid_to`
|
|
90
|
+
must be after `valid_from` when both are set.
|
|
91
|
+
|
|
92
|
+
## A price type is not access control
|
|
93
|
+
|
|
94
|
+
**Setting a price's `price_type` to `employee` does nothing on its own.** Type and
|
|
95
|
+
role are independent columns. A price typed `employee` with `role_id` left blank is
|
|
96
|
+
visible to every customer, guests included — the name is just a label for the admin
|
|
97
|
+
UI and reporting; it grants no eligibility by itself. Two of the six seeded types,
|
|
98
|
+
`wholesale` and `employee`, exist specifically to invite this mistake.
|
|
99
|
+
|
|
100
|
+
If a price should only be available to a specific group, you must **also** set `role`:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
employee_role = Spree::Role.find_or_create_by!(name: "employee")
|
|
104
|
+
employee_type = SolidusAdvancedPricing::PriceType.find_by(code: "employee")
|
|
105
|
+
|
|
106
|
+
variant.prices.create!(
|
|
107
|
+
amount: 45,
|
|
108
|
+
currency: "USD",
|
|
109
|
+
price_type: employee_type,
|
|
110
|
+
role: employee_role
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
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.
|
|
116
|
+
|
|
117
|
+
## How a price is chosen
|
|
118
|
+
|
|
119
|
+
For a given variant, currency, and requesting customer, `PriceSelector` picks a price
|
|
120
|
+
in three steps:
|
|
121
|
+
|
|
122
|
+
1. **Filter.** A price survives only if: its currency matches exactly; the current
|
|
123
|
+
time falls inside its validity window (or it has none); and it's visible to the
|
|
124
|
+
customer — `role_id` is blank, or the customer holds that role. If a price type is
|
|
125
|
+
pinned (see [Admin vs. customer lookups](#admin-vs-customer-lookups) below),
|
|
126
|
+
non-matching types are dropped here too.
|
|
127
|
+
2. **Country specificity.** Among what survives, a price matching the customer's
|
|
128
|
+
country beats every country-agnostic (`country_iso: nil`) price — **even when the
|
|
129
|
+
agnostic price is cheaper.** This matches core Solidus behavior.
|
|
130
|
+
3. **Cheapest wins.** Within the winning country bucket, the lowest `amount` is
|
|
131
|
+
selected. Ties are broken by price type position (lower `position` wins), then by
|
|
132
|
+
most recently updated, then by highest id.
|
|
133
|
+
|
|
134
|
+
### Role is eligibility, never specificity
|
|
135
|
+
|
|
136
|
+
A role only decides *whether* a price is a candidate, never how strongly it competes
|
|
137
|
+
once it is one. A role-targeted price set **above** the untargeted price will never
|
|
138
|
+
apply, because step 3 always takes the cheapest survivor:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
Retail (no role): $100
|
|
142
|
+
Wholesale (wholesale): $120
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A wholesale customer still pays $100 — their role makes the $120 price *eligible*,
|
|
146
|
+
but the untargeted $100 price is cheaper and wins. This is intended, and it's the one
|
|
147
|
+
genuinely surprising consequence of the design: if you want a role to guarantee a
|
|
148
|
+
better price, that price has to actually be cheaper than what an untargeted customer
|
|
149
|
+
would pay.
|
|
150
|
+
|
|
151
|
+
### Admin vs. customer lookups
|
|
152
|
+
|
|
153
|
+
`price_type_id` has three meaningful states in a pricing lookup:
|
|
154
|
+
|
|
155
|
+
| `price_type_id` | Meaning |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `nil` | Untyped base prices only. |
|
|
158
|
+
| an integer id | Only that type. |
|
|
159
|
+
| `:any` | No type filter — every type competes. |
|
|
160
|
+
|
|
161
|
+
`SolidusAdvancedPricing::PricingOptions.default_price_attributes` pins
|
|
162
|
+
`price_type_id` to `nil` and `role_id` to `nil`. This is what
|
|
163
|
+
`Spree::Variant#default_price_or_build` and the admin price form build against, so an
|
|
164
|
+
admin always edits the untyped base price rather than accidentally landing on a
|
|
165
|
+
cheaper sale or wholesale row.
|
|
166
|
+
|
|
167
|
+
Customer-facing lookups (`PricingOptions.from_line_item`, `.from_context`) set
|
|
168
|
+
`price_type_id` to `:any` instead — every type competes — and populate
|
|
169
|
+
`customer_role_ids` from the current user's roles, so the full set of eligible prices
|
|
170
|
+
is considered. `:any` is a selector-only sentinel; it never reaches `Spree::Price`
|
|
171
|
+
as a column value (`PricingOptions#search_arguments` strips it before querying).
|
|
172
|
+
|
|
173
|
+
## Backward compatibility
|
|
174
|
+
|
|
175
|
+
A store with no typed, windowed, or role-targeted prices — i.e. one that has only run
|
|
176
|
+
the migrations and never set the new columns beyond their defaults — behaves exactly
|
|
177
|
+
like core Solidus. This is pinned by
|
|
178
|
+
[`spec/models/spree/backward_compatibility_spec.rb`](spec/models/spree/backward_compatibility_spec.rb).
|
|
179
|
+
|
|
180
|
+
One real consequence to be aware of: if a variant's *only* prices all have validity
|
|
181
|
+
windows and none is currently open, `variant.price` returns `nil` — the same thing
|
|
182
|
+
core does when no price matches at all, not an error. Keep at least one open-ended
|
|
183
|
+
(`valid_from`/`valid_to` both blank) base price per variant if you don't want a
|
|
184
|
+
variant to become unpriced outside its scheduled windows.
|
|
185
|
+
|
|
186
|
+
A second consequence: when a variant has no untyped base price at all (a MAP-only
|
|
187
|
+
variant, for example), `variant.price` / `default_price` (and therefore
|
|
188
|
+
`display_price`, `display_amount` and `has_default_price?`) fall back to the
|
|
189
|
+
cheapest eligible typed price instead of returning nothing, so the admin never
|
|
190
|
+
shows a blank price field. The fallback only relaxes the type filter — currency,
|
|
191
|
+
the validity window and role visibility still apply, so a variant whose only typed
|
|
192
|
+
price is expired or role-targeted still comes back unpriced. `base_price`
|
|
193
|
+
deliberately does **not** fall back: it is the compare-at value shown next to the
|
|
194
|
+
current price, and if it silently became equal to the current price there would
|
|
195
|
+
be nothing left to strike through.
|
|
196
|
+
|
|
197
|
+
## Caching
|
|
198
|
+
|
|
199
|
+
Pricing lookups are cached, and the cache key includes:
|
|
200
|
+
|
|
201
|
+
- the attributes core already keys on (currency, country, etc.)
|
|
202
|
+
- the customer's *pricing-relevant* roles
|
|
203
|
+
- a coarse time bucket
|
|
204
|
+
|
|
205
|
+
The role component is mandatory, not optional: without it, the first customer to
|
|
206
|
+
resolve a variant's price — guest or not — would have that price cached and served to
|
|
207
|
+
everyone else who hits the same key, including a guest being served a role-gated
|
|
208
|
+
wholesale price.
|
|
209
|
+
|
|
210
|
+
Roles are narrowed to only those roles that some price actually references
|
|
211
|
+
(`SolidusAdvancedPricing::PriceTypeCache.pricing_role_ids`), not the customer's full
|
|
212
|
+
role list. This keeps cache key cardinality low without affecting which price is
|
|
213
|
+
selected.
|
|
214
|
+
|
|
215
|
+
The time bucket width is controlled by:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
Spree::Config.advanced_pricing_cache_granularity = 60 # seconds, default
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Setting it to `0` disables the time component of the cache key entirely. This trades
|
|
222
|
+
a slightly more precise validity boundary for cheaper caching: a price whose window
|
|
223
|
+
just closed can keep being served from cache until something else invalidates the
|
|
224
|
+
key. The default of 60 seconds bounds how stale a validity-window transition can be.
|
|
225
|
+
|
|
226
|
+
## Admin
|
|
227
|
+
|
|
228
|
+
**Legacy backend** (`solidus_backend`) gets the full experience:
|
|
229
|
+
|
|
230
|
+
- The price form gains price type, role, validity (`valid_from`/`valid_to`), and
|
|
231
|
+
admin notes fields, added via Deface overrides
|
|
232
|
+
(`app/overrides/spree/admin/prices/_form/add_advanced_fields.html.erb.deface`).
|
|
233
|
+
- The prices tables (both the single-variant and master-variant listings) gain
|
|
234
|
+
columns for type, role, and validity.
|
|
235
|
+
- Price types get full CRUD at `/admin/price_types` (`spree.admin_price_types_path`),
|
|
236
|
+
linked from the settings sidebar (Deface override on
|
|
237
|
+
`spree/admin/shared/_configuration_menu`).
|
|
238
|
+
|
|
239
|
+
**solidus_admin** gets a **price types index only**
|
|
240
|
+
(`SolidusAdmin::PriceTypes::Index::Component`, at `solidus_admin.price_types_path`),
|
|
241
|
+
linked from the main navigation (registered via `SolidusAdmin::Config.menu_items`
|
|
242
|
+
in `lib/solidus_advanced_pricing/engine.rb`) whenever `solidus_admin` is mounted.
|
|
243
|
+
It inherits from `SolidusAdmin::ResourcesController`, which is why the gem requires
|
|
244
|
+
Solidus 4.5 (see Requirements).
|
|
245
|
+
Its rows link back to the legacy backend for edit/new; per-variant price management
|
|
246
|
+
stays entirely in the legacy backend. This is deliberate: upstream Solidus's new
|
|
247
|
+
admin has no prices screen of its own yet, and building one here would mean guessing
|
|
248
|
+
at a design that hasn't landed upstream. Be aware that this component is built
|
|
249
|
+
against pre-1.0 `solidus_admin` internals (`SolidusAdmin::UI::Pages::Index::Component`,
|
|
250
|
+
`SolidusAdmin::ResourcesController`) and may need updating when you upgrade
|
|
251
|
+
`solidus_admin`.
|
|
252
|
+
|
|
253
|
+
## API
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
GET /api/variants/:variant_id/prices
|
|
257
|
+
GET /api/variants/:variant_id/prices/:id
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Each price is serialized with its advanced attributes — `price_type_code`,
|
|
261
|
+
`price_type_name`, `role_id`, `valid_from`, `valid_to` — and `admin_notes` is included
|
|
262
|
+
only for a caller who can update that price.
|
|
263
|
+
|
|
264
|
+
**This endpoint is admin-facing.** Core's `DefaultCustomer` permission set (what every
|
|
265
|
+
non-admin API token gets) grants no rights on `Spree::Price` at all, so a non-admin
|
|
266
|
+
token receives `401 Unauthorized` on the whole endpoint, not a filtered response.
|
|
267
|
+
Headless storefronts do not need this endpoint for normal pricing: core's variants
|
|
268
|
+
endpoint already serializes `price` and `display_price` through this gem's selector
|
|
269
|
+
automatically, because the selector is registered globally via
|
|
270
|
+
`Spree::Config.variant_price_selector_class`.
|
|
271
|
+
|
|
272
|
+
## Storefront: compare-at (strikethrough) pricing
|
|
273
|
+
|
|
274
|
+
`Spree::Variant#price_of_type` and `#base_price` fetch a specific typed price
|
|
275
|
+
alongside the customer's normal price, so a storefront can render a struck-through
|
|
276
|
+
"was" price next to an active sale:
|
|
277
|
+
|
|
278
|
+
```erb
|
|
279
|
+
<% base = variant.base_price(current_pricing_options) %>
|
|
280
|
+
<% current = variant.price_for_options(current_pricing_options) %>
|
|
281
|
+
|
|
282
|
+
<% if base && current && base.amount > current.amount %>
|
|
283
|
+
<s><%= base.display_amount %></s>
|
|
284
|
+
<% end %>
|
|
285
|
+
<%= current.display_amount %>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`price_of_type` takes the same pricing options as `price_for_options`, so the
|
|
289
|
+
comparison price respects the customer's currency, country, roles, and the current
|
|
290
|
+
time — not an admin-context lookup. `nil` means the base (untyped) price, which is
|
|
291
|
+
what `base_price` passes under the hood. `price_of_type` also accepts a `PriceType`
|
|
292
|
+
record, an id, or a type code (e.g. `variant.price_of_type("sale", current_pricing_options)`);
|
|
293
|
+
an unresolvable code raises `ArgumentError` rather than silently falling back to the
|
|
294
|
+
base price.
|
|
295
|
+
|
|
296
|
+
## Known limitations
|
|
297
|
+
|
|
298
|
+
- **Search and taxon filtering ignore validity windows.** `search_arguments`
|
|
299
|
+
(`SolidusAdvancedPricing::PricingOptions#search_arguments`), which core's product
|
|
300
|
+
search and taxon filtering query directly, is a plain equality hash passed to
|
|
301
|
+
`Spree::Price.where(...)`. It can express type and role but has no way to express
|
|
302
|
+
"and the validity window contains now." A variant whose only price expired
|
|
303
|
+
yesterday can still appear in search results, even though
|
|
304
|
+
`Spree::Variant#price_for_options` correctly returns `nil` for it and
|
|
305
|
+
`Spree::Variant.with_prices` (used by listings that call it directly) correctly
|
|
306
|
+
excludes it. Closing this gap would require overriding the two core search call
|
|
307
|
+
sites as well, which this gem does not currently do.
|
|
308
|
+
- **No role exclusion.** Only inclusion is expressible via `role_id`. "Everyone
|
|
309
|
+
except employees" cannot be written directly — approximate it with one targeted
|
|
310
|
+
price per role you *do* want to include.
|
|
311
|
+
- **One role per price.** `role_id` targets a single `Spree::Role`. A customer may
|
|
312
|
+
hold several roles and will see every price targeted at any role they hold (the
|
|
313
|
+
cheapest of them wins, per [how a price is chosen](#how-a-price-is-chosen)) — but a
|
|
314
|
+
single price row can't itself target more than one role.
|
|
315
|
+
|
|
316
|
+
## Relationship to solidus_promotions
|
|
317
|
+
|
|
318
|
+
This gem is for prices that genuinely *are* a different amount for a given customer
|
|
319
|
+
or window: wholesale tiers, price lists, a scheduled rollover to a sale price. It
|
|
320
|
+
does not reimplement promotion machinery — no codes, no usage limits, no stacking
|
|
321
|
+
rules. A `sale`-typed price with a validity window is a perfectly good way to model a
|
|
322
|
+
time-boxed sale here.
|
|
323
|
+
|
|
324
|
+
For discounting an existing price — percentage or fixed-amount off, promo codes,
|
|
325
|
+
usage limits, stacking behavior — use
|
|
326
|
+
[`solidus_promotions`](https://github.com/solidusio/solidus_promotions) instead.
|
|
327
|
+
`solidus_promotions` does ship `Benefits::AdvertisePrice` and price-level conditions,
|
|
328
|
+
and it can look similar to what this gem does on the surface, but a promotion
|
|
329
|
+
*discounts* a price rather than replacing it. For B2B-style pricing that is the wrong
|
|
330
|
+
semantics: the retail price still leaks (it's the "was" price the discount is
|
|
331
|
+
computed from), and the sale reports as promotional revenue rather than as the
|
|
332
|
+
customer's actual price. Use this gem when the price itself should simply be
|
|
333
|
+
different for that customer or window.
|
|
334
|
+
|
|
335
|
+
## Development
|
|
336
|
+
|
|
337
|
+
```shell
|
|
338
|
+
bin/rake extension:test_app
|
|
339
|
+
bundle exec rspec
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
To run [Rubocop](https://github.com/bbatsov/rubocop) static code analysis run
|
|
343
|
+
|
|
344
|
+
```shell
|
|
345
|
+
bundle exec rubocop
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
When testing your application's integration with this extension you may use its factories.
|
|
349
|
+
You can load Solidus core factories along with this extension's factories using this statement:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
SolidusDevSupport::TestingSupport::Factories.load_for(SolidusAdvancedPricing::Engine)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Running the sandbox
|
|
356
|
+
|
|
357
|
+
To run this extension in a sandboxed Solidus application, you can run `bin/sandbox`. The path for
|
|
358
|
+
the sandbox app is `./sandbox` and `bin/rails` will forward any Rails commands to
|
|
359
|
+
`sandbox/bin/rails`.
|
|
360
|
+
|
|
361
|
+
Here's an example:
|
|
362
|
+
|
|
363
|
+
```
|
|
364
|
+
$ bin/rails server
|
|
365
|
+
=> Booting Puma
|
|
366
|
+
=> Rails 6.0.2.1 application starting in development
|
|
367
|
+
* Listening on tcp://127.0.0.1:3000
|
|
368
|
+
Use Ctrl-C to stop
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Releasing new versions
|
|
372
|
+
|
|
373
|
+
Please refer to the [dedicated page](https://github.com/solidusio/solidus/wiki/How-to-release-extensions) in the Solidus wiki.
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
Copyright (c) 2026 Patrick McMorran, released under the New BSD License.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "solidus_dev_support/rake_tasks"
|
|
5
|
+
SolidusDevSupport::RakeTasks.install
|
|
6
|
+
|
|
7
|
+
task default: "extension:specs"
|
|
8
|
+
|
|
9
|
+
# The stock dummy app template (spree/testing_support, as of solidus_dev_support
|
|
10
|
+
# 2.12) predates solidus_admin and neither mounts its engine nor builds its
|
|
11
|
+
# assets, so a freshly generated dummy app 404s on every admin page. Both fixes
|
|
12
|
+
# below only apply when solidus_admin is actually a dependency of this gem.
|
|
13
|
+
if Rake::Task.task_defined?("extension:test_app") && Gem.loaded_specs.key?("solidus_admin")
|
|
14
|
+
Rake::Task["extension:test_app"].enhance do
|
|
15
|
+
dummy_root = File.expand_path("spec/dummy", __dir__)
|
|
16
|
+
|
|
17
|
+
# solidus_admin ships a precompiled tailwind.css in the released gem, but the
|
|
18
|
+
# git-source checkout used in dev/CI omits it (built only by `rake release`).
|
|
19
|
+
# Without it, Sprockets raises FileNotFound for any admin page. A stub is
|
|
20
|
+
# sufficient here since these specs don't assert on styling.
|
|
21
|
+
tailwind_css = File.join(dummy_root, "app/assets/builds/solidus_admin/tailwind.css")
|
|
22
|
+
unless File.exist?(tailwind_css)
|
|
23
|
+
require "fileutils"
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(tailwind_css))
|
|
25
|
+
FileUtils.touch(tailwind_css)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Per the solidus_admin README, SolidusAdmin::Engine must be mounted *before*
|
|
29
|
+
# Spree::Core::Engine so its routes take precedence over the legacy backend's
|
|
30
|
+
# for any resource both admins define (otherwise Spree::Core::Engine, mounted
|
|
31
|
+
# at '/', claims every request first and solidus_admin is never reached).
|
|
32
|
+
routes_path = File.join(dummy_root, "config/routes.rb")
|
|
33
|
+
routes = File.read(routes_path)
|
|
34
|
+
unless routes.include?("SolidusAdmin::Engine")
|
|
35
|
+
File.write(routes_path, routes.sub(
|
|
36
|
+
"Rails.application.routes.draw do",
|
|
37
|
+
"Rails.application.routes.draw do\n mount SolidusAdmin::Engine, at: '/admin' if defined?(SolidusAdmin::Engine)"
|
|
38
|
+
))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
module Spree
|
|
5
|
+
module AppConfigurationDecorator
|
|
6
|
+
def self.prepended(base)
|
|
7
|
+
return if base.defined_preferences.include?(:advanced_pricing_cache_granularity)
|
|
8
|
+
|
|
9
|
+
# Seconds of granularity for the time component of pricing cache keys.
|
|
10
|
+
# 0 omits time from the key entirely; prices may then be cached past their window.
|
|
11
|
+
base.preference :advanced_pricing_cache_granularity, :integer, default: 60
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
::Spree::AppConfiguration.prepend self
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
module Spree
|
|
5
|
+
module PermittedAttributesDecorator
|
|
6
|
+
# `prepend`ing PermittedAttributes.singleton_class hands `prepended` the
|
|
7
|
+
# singleton class itself, and mattr_accessor rejects being called on
|
|
8
|
+
# one ("module attributes should be defined directly on class, not
|
|
9
|
+
# singleton") -- so the accessor is defined on the module directly.
|
|
10
|
+
#
|
|
11
|
+
# Not a module-level constant: solidus_support `load`s this file on
|
|
12
|
+
# every to_prepare, which would otherwise warn on every reload
|
|
13
|
+
# ("already initialized constant").
|
|
14
|
+
def self.prepended(_base)
|
|
15
|
+
target = ::Spree::PermittedAttributes
|
|
16
|
+
|
|
17
|
+
unless target.respond_to?(:price_attributes)
|
|
18
|
+
target.mattr_accessor(:price_attributes) { [:amount, :currency, :country_iso] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
target.price_attributes |= [:price_type_id, :role_id, :valid_from, :valid_to, :admin_notes]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
::Spree::PermittedAttributes.singleton_class.prepend self
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
module Spree
|
|
5
|
+
module PriceDecorator
|
|
6
|
+
def self.prepended(base)
|
|
7
|
+
# solidus_support reloads decorators via bare `load` on every to_prepare;
|
|
8
|
+
# re-running this method would redefine the association and its
|
|
9
|
+
# callbacks (valid_to_after_valid_from, after_commit) each time.
|
|
10
|
+
return if base.reflect_on_association(:price_type)
|
|
11
|
+
|
|
12
|
+
# with_discarded: a price keeps its type after an admin retires it.
|
|
13
|
+
# optional: true because price_type_id nil is a real value here — the
|
|
14
|
+
# untyped base price — not a validation gap.
|
|
15
|
+
base.belongs_to :price_type,
|
|
16
|
+
-> { with_discarded },
|
|
17
|
+
class_name: "SolidusAdvancedPricing::PriceType",
|
|
18
|
+
inverse_of: :prices,
|
|
19
|
+
optional: true
|
|
20
|
+
|
|
21
|
+
base.belongs_to :role,
|
|
22
|
+
class_name: "::Spree::Role",
|
|
23
|
+
optional: true
|
|
24
|
+
|
|
25
|
+
base.validate :valid_to_after_valid_from
|
|
26
|
+
|
|
27
|
+
# valid_to is exclusive so a window ending at midnight and the next one starting at midnight do not both match.
|
|
28
|
+
base.scope :valid_at, ->(time) {
|
|
29
|
+
where(arel_table[:valid_from].eq(nil).or(arel_table[:valid_from].lteq(time)))
|
|
30
|
+
.where(arel_table[:valid_to].eq(nil).or(arel_table[:valid_to].gt(time)))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
base.scope :visible_to_roles, ->(role_ids) {
|
|
34
|
+
where(role_id: [nil, *role_ids])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
base.scope :for_price_type, ->(price_type) {
|
|
38
|
+
where(price_type_id: price_type)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
base.after_commit { SolidusAdvancedPricing::PriceTypeCache.clear }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def valid_to_after_valid_from
|
|
47
|
+
return if valid_from.blank? || valid_to.blank?
|
|
48
|
+
return if valid_to > valid_from
|
|
49
|
+
|
|
50
|
+
errors.add(:valid_to, :must_be_after_valid_from)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
::Spree::Price.prepend self
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
module Spree
|
|
5
|
+
module RoleDecorator
|
|
6
|
+
def self.prepended(base)
|
|
7
|
+
# No re-run guard needed: unlike belongs_to, a symbol-filtered callback
|
|
8
|
+
# dedupes on re-registration, so this is already safe under to_prepare reloads.
|
|
9
|
+
base.before_destroy :prevent_destroying_referenced_role
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def prevent_destroying_referenced_role
|
|
15
|
+
return unless ::Spree::Price.with_discarded.exists?(role_id: id)
|
|
16
|
+
|
|
17
|
+
errors.add(:base, :referenced_by_prices)
|
|
18
|
+
throw :abort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
::Spree::Role.prepend self
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
module Spree
|
|
5
|
+
module VariantDecorator
|
|
6
|
+
def self.prepended(base)
|
|
7
|
+
base.singleton_class.prepend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
# Core checks only currency and country, so an expired or role-targeted
|
|
12
|
+
# price still makes a variant look purchasable in listings.
|
|
13
|
+
def with_prices(pricing_options = ::Spree::Config.default_pricing_options)
|
|
14
|
+
relation = ::Spree::Price
|
|
15
|
+
.where(::Spree::Variant.arel_table[:id].eq(::Spree::Price.arel_table[:variant_id]))
|
|
16
|
+
.where(currency: pricing_options.currency)
|
|
17
|
+
.where(country_iso: [pricing_options.country_iso, nil].uniq)
|
|
18
|
+
|
|
19
|
+
relation = relation.valid_at(pricing_options.at) if pricing_options.try(:at)
|
|
20
|
+
if pricing_options.respond_to?(:customer_role_ids)
|
|
21
|
+
relation = relation.visible_to_roles(pricing_options.customer_role_ids)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
where(relation.arel.exists)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# nil means the untyped base price. Accepts a PriceType, an id, or a code.
|
|
29
|
+
def price_of_type(price_type, pricing_options = ::Spree::Config.default_pricing_options)
|
|
30
|
+
price_selector.price_for_options(
|
|
31
|
+
pricing_options.with(price_type_id: SolidusAdvancedPricing.resolve_price_type_id(price_type))
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The untyped price a sale or role-targeted price undercuts -- the value to
|
|
36
|
+
# strike through in a storefront.
|
|
37
|
+
def base_price(pricing_options = ::Spree::Config.default_pricing_options)
|
|
38
|
+
price_of_type(nil, pricing_options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A MAP-restricted variant may carry no untyped price at all; fall back so
|
|
42
|
+
# variant.price is never blank when some price exists.
|
|
43
|
+
def default_price
|
|
44
|
+
super || price_selector.price_for_options(
|
|
45
|
+
::Spree::Config.default_pricing_options.with(price_type_id: :any)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
::Spree::Variant.prepend self
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|