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
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusAdvancedPricing
4
+ VERSION = "0.1.0"
5
+ 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