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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +53 -0
  3. data/.gem_release.yml +5 -0
  4. data/.github/stale.yml +1 -0
  5. data/.github/workflows/lint.yml +25 -0
  6. data/.github/workflows/test.yml +86 -0
  7. data/.github_changelog_generator +2 -0
  8. data/.gitignore +21 -0
  9. data/.rspec +2 -0
  10. data/.rubocop.yml +5 -0
  11. data/CHANGELOG.md +3 -0
  12. data/Gemfile +64 -0
  13. data/LICENSE +26 -0
  14. data/README.md +377 -0
  15. data/Rakefile +41 -0
  16. data/app/assets/javascripts/spree/backend/solidus_advanced_pricing.js +2 -0
  17. data/app/assets/javascripts/spree/frontend/solidus_advanced_pricing.js +2 -0
  18. data/app/assets/stylesheets/spree/backend/solidus_advanced_pricing.css +4 -0
  19. data/app/assets/stylesheets/spree/frontend/solidus_advanced_pricing.css +4 -0
  20. data/app/decorators/models/solidus_advanced_pricing/spree/app_configuration_decorator.rb +17 -0
  21. data/app/decorators/models/solidus_advanced_pricing/spree/permitted_attributes_decorator.rb +27 -0
  22. data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +56 -0
  23. data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +24 -0
  24. data/app/decorators/models/solidus_advanced_pricing/spree/variant_decorator.rb +52 -0
  25. data/app/models/solidus_advanced_pricing/price_selector.rb +73 -0
  26. data/app/models/solidus_advanced_pricing/price_type.rb +80 -0
  27. data/app/models/solidus_advanced_pricing/price_type_cache.rb +45 -0
  28. data/app/models/solidus_advanced_pricing/pricing_options.rb +107 -0
  29. data/app/overrides/spree/admin/prices/_form/add_advanced_fields.html.erb.deface +2 -0
  30. data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_columns.html.erb.deface +2 -0
  31. data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_headers.html.erb.deface +4 -0
  32. data/app/overrides/spree/admin/prices/_table/add_advanced_columns.html.erb.deface +2 -0
  33. data/app/overrides/spree/admin/prices/_table/add_advanced_headers.html.erb.deface +4 -0
  34. data/app/overrides/spree/admin/shared/_configuration_menu/add_price_types_link.html.erb.deface +4 -0
  35. data/bin/console +17 -0
  36. data/bin/rails +7 -0
  37. data/bin/rails-engine +13 -0
  38. data/bin/rails-sandbox +16 -0
  39. data/bin/rake +7 -0
  40. data/bin/sandbox +85 -0
  41. data/bin/setup +8 -0
  42. data/config/locales/en.yml +44 -0
  43. data/config/routes.rb +34 -0
  44. data/db/migrate/20260915000001_create_solidus_advanced_pricing_price_types.rb +16 -0
  45. data/db/migrate/20260915000002_seed_solidus_advanced_pricing_price_types.rb +34 -0
  46. data/db/migrate/20260915000003_add_advanced_pricing_to_spree_prices.rb +21 -0
  47. data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +351 -0
  48. data/lib/components/admin/solidus_admin/price_types/index/component.rb +65 -0
  49. data/lib/controllers/admin/solidus_admin/price_types_controller.rb +26 -0
  50. data/lib/controllers/api/spree/api/prices_controller.rb +32 -0
  51. data/lib/controllers/backend/spree/admin/price_types_controller.rb +40 -0
  52. data/lib/generators/solidus_advanced_pricing/install/install_generator.rb +33 -0
  53. data/lib/generators/solidus_advanced_pricing/install/templates/initializer.rb +7 -0
  54. data/lib/solidus_advanced_pricing/configuration.rb +21 -0
  55. data/lib/solidus_advanced_pricing/engine.rb +41 -0
  56. data/lib/solidus_advanced_pricing/testing_support/factories/price_type_factory.rb +13 -0
  57. data/lib/solidus_advanced_pricing/testing_support/factories.rb +4 -0
  58. data/lib/solidus_advanced_pricing/version.rb +5 -0
  59. data/lib/solidus_advanced_pricing.rb +19 -0
  60. data/lib/views/api/spree/api/prices/_price.json.jbuilder +8 -0
  61. data/lib/views/api/spree/api/prices/index.json.jbuilder +3 -0
  62. data/lib/views/api/spree/api/prices/show.json.jbuilder +1 -0
  63. data/lib/views/backend/spree/admin/price_types/_form.html.erb +20 -0
  64. data/lib/views/backend/spree/admin/price_types/edit.html.erb +9 -0
  65. data/lib/views/backend/spree/admin/price_types/index.html.erb +31 -0
  66. data/lib/views/backend/spree/admin/price_types/new.html.erb +9 -0
  67. data/lib/views/backend/spree/admin/prices/_advanced_columns.html.erb +7 -0
  68. data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +46 -0
  69. data/solidus_advanced_pricing.gemspec +39 -0
  70. metadata +228 -0
data/README.md ADDED
@@ -0,0 +1,377 @@
1
+ # Solidus Advanced Pricing
2
+
3
+ [![CircleCI](https://circleci.com/gh/solidusio-contrib/solidus_advanced_pricing.svg?style=shield)](https://circleci.com/gh/solidusio-contrib/solidus_advanced_pricing)
4
+ [![codecov](https://codecov.io/gh/solidusio-contrib/solidus_advanced_pricing/branch/main/graph/badge.svg)](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,2 @@
1
+ // Placeholder manifest file.
2
+ // the installer will append this file to the app vendored assets here: vendor/assets/javascripts/spree/backend/all.js'
@@ -0,0 +1,2 @@
1
+ // Placeholder manifest file.
2
+ // the installer will append this file to the app vendored assets here: vendor/assets/javascripts/spree/frontend/all.js'
@@ -0,0 +1,4 @@
1
+ /*
2
+ Placeholder manifest file.
3
+ the installer will append this file to the app vendored assets here: 'vendor/assets/stylesheets/spree/backend/all.css'
4
+ */
@@ -0,0 +1,4 @@
1
+ /*
2
+ Placeholder manifest file.
3
+ the installer will append this file to the app vendored assets here: 'vendor/assets/stylesheets/spree/frontend/all.css'
4
+ */
@@ -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