@spree/docs 0.1.305 → 0.1.307

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.
@@ -249,6 +249,20 @@ spree generate migration AddPositionToSpreeBrands position:integer # Rails buil
249
249
 
250
250
  `spree:api_resource` scaffolds the full v3 surface: model, migration, Store + Admin controllers and serializers, factory, controller specs, routes, and the `read_<resources>` / `write_<resources>` permissions that let staff roles and secret API keys reach the Admin API.
251
251
 
252
+ ### `spree filters types`
253
+
254
+ Generate TypeScript declarations for your app's list filters, so the SDKs accept and autocomplete the filters and sort fields your app adds. It reads them from the running app and writes one file per run, so run it once for each app that uses an SDK, and again after you change a filter.
255
+
256
+ ```bash
257
+ spree filters types --api store --out apps/storefront/src/types/spree-filters.d.ts # @spree/sdk
258
+ spree filters types --api admin --out apps/dashboard/src/types/spree-filters.d.ts # @spree/admin-sdk
259
+ spree filters types --api seller --out apps/seller-dashboard/src/types/spree-filters.d.ts # @spree/seller-sdk
260
+ ```
261
+
262
+ `--from <file>` reads the tables from `bin/rails spree:api:filter_tables` output instead of the running app.
263
+
264
+ See [using your own filters from TypeScript](../core-concepts/search-filtering.md#using-your-own-filters-from-typescript).
265
+
252
266
  ### `spree migrate`
253
267
 
254
268
  Install pending Spree migrations from gems, then run `db:migrate` — the canonical post-update sequence.
@@ -94,8 +94,8 @@ const calculators = await adminClient.deliveryMethods.calculators()
94
94
  await adminClient.deliveryMethods.create({
95
95
  name: 'Standard shipping',
96
96
  delivery_zone_id: 'dz_xxx',
97
- calculator_type: 'flat_rate',
98
- calculator_attributes: {
97
+ calculator: {
98
+ type: 'flat_rate',
99
99
  preferences: { amount: '7.00', currency: 'USD' },
100
100
  },
101
101
  })
@@ -38,7 +38,7 @@ erDiagram
38
38
  | `default` | Exactly one channel per store is the default. Used as a fallback when no channel header is present and as the auto-publish target for new products | `true` |
39
39
  | `storefront_access` | Controls what an anonymous visitor may see: `public`, `prices_hidden`, or `login_required`. Unset inherits the store's setting. See [Storefront Access Gating](#storefront-access-gating) | `login_required` |
40
40
  | `guest_checkout` | Whether an order may be placed without an account on this channel. Unset inherits the store's setting | `false` |
41
- | `preferred_order_routing_strategy` | Optional per-channel override of the store's [Order Routing](../how-to/custom-order-routing.md) strategy | `rules` |
41
+ | `order_routing_strategy` | Optional per-channel override of the store's [Order Routing](../how-to/custom-order-routing.md) strategy | `rules` |
42
42
 
43
43
  `code` is normalized to a URL-safe slug on save — `POS` becomes `pos`, `Point of Sale!` becomes `point-of-sale`. Leaving `code` blank derives it from `name`.
44
44
 
@@ -132,13 +132,13 @@ Both controls fall back to the owning [Store](stores.md) when the channel's own
132
132
  ```typescript Admin SDK
133
133
  // Gate a channel behind sign-in, and require accounts at checkout
134
134
  await adminClient.channels.update('ch_wholesale', {
135
- preferred_storefront_access: 'login_required',
136
- preferred_guest_checkout: false,
135
+ storefront_access: 'login_required',
136
+ guest_checkout: false,
137
137
  })
138
138
 
139
139
  // Clear the channel value to inherit the store's default
140
140
  await adminClient.channels.update('ch_wholesale', {
141
- preferred_storefront_access: null,
141
+ storefront_access: null,
142
142
  })
143
143
  ```
144
144
 
@@ -146,7 +146,7 @@ await adminClient.channels.update('ch_wholesale', {
146
146
  curl -X PATCH 'https://api.mystore.com/api/v3/admin/channels/ch_wholesale' \
147
147
  -H 'X-Spree-API-Key: sk_xxx' \
148
148
  -H 'Content-Type: application/json' \
149
- -d '{ "preferred_storefront_access": "login_required", "preferred_guest_checkout": false }'
149
+ -d '{ "storefront_access": "login_required", "guest_checkout": false }'
150
150
  ```
151
151
 
152
152
 
@@ -295,14 +295,14 @@ Price history tracking is enabled by default. To disable it (e.g., for non-EU st
295
295
 
296
296
 
297
297
  ```typescript Admin SDK
298
- await client.store.update({ preferred_track_price_history: false })
298
+ await client.store.update({ track_price_history: false })
299
299
  ```
300
300
 
301
301
  ```bash cURL
302
302
  curl -X PATCH 'https://api.mystore.com/api/v3/admin/store' \
303
303
  -H 'X-Spree-API-Key: sk_xxx' \
304
304
  -H 'Content-Type: application/json' \
305
- -d '{ "preferred_track_price_history": false }'
305
+ -d '{ "track_price_history": false }'
306
306
  ```
307
307
 
308
308
 
@@ -245,6 +245,91 @@ The Store API uses query parameters prefixed with `q[]` for filtering any resour
245
245
 
246
246
  > **INFO:** Only attributes explicitly allowed by each resource can be used for filtering. Attempting to filter on unsupported fields will be silently ignored.
247
247
 
248
+ ### Making a field filterable
249
+
250
+ A resource's filters come from its model's allowlists, and nowhere else. Spree generates its API reference and SDK types from them, so the [Querying](../../api-reference/admin-api/querying.md#which-filters-an-endpoint-accepts) reference lists what core declares. Filters your app adds take effect in the API immediately; to use them from TypeScript, declare them as shown below, since the published SDK types are generated from core alone.
251
+
252
+ ```ruby server/config/initializers/spree.rb
253
+ Spree.ransack.add_attribute(Spree::Product, :erp_id)
254
+ Spree.ransack.add_association(Spree::Product, :brand)
255
+ Spree.ransack.add_scope(Spree::Product, :featured, type: 'boolean')
256
+ Spree.ransack.add_scope(Spree::Product, :by_brand, type: 'id')
257
+ ```
258
+
259
+ Give a model a `search` filter (`q[search]=term`) by naming the attributes it matches, partially and case-insensitively. Attributes of associated records are named with the association as a prefix:
260
+
261
+ ```ruby server/app/models/spree/brand_decorator.rb
262
+ base.search_by :name, :code
263
+ ```
264
+
265
+ `search` is offered to the Store and Seller APIs only when each attribute it matches is one that API may filter on. The API reference describes it from the attributes.
266
+
267
+ Declare the `type` of a scope's argument: `'boolean'` for a scope taking none (`q[featured]=true`), or `'text'`, `'id'`, `'decimal'`, `'integer'`, `'date'` or `'datetime'` for one value. A list of kinds (`%w[decimal decimal]`) means several arguments in order, and `{ list: 'id' }` means any number of them. A scope declared without a type is published as taking one text value.
268
+
269
+ Every entry is a filter any caller of that API can run. Keep back-office fields out of the Store API with `private_ransackable_attributes`, and add associations the storefront may filter through to `storefront_ransackable_associations`.
270
+
271
+ ### Using your own filters from TypeScript
272
+
273
+ The SDKs type each `list()` call with the filters Spree itself declares. A filter your app adds works in the API at once, but TypeScript rejects it until it is declared, because the published types cannot know about it. There are three ways to use it.
274
+
275
+ **Generate the declarations (recommended).** This reads your app's filters, the ones Spree declares and the ones you added, and writes a declaration file for one SDK. Each frontend uses its own SDK, so generate a file for each app in your project, from the project root:
276
+
277
+ | App | SDK | `--api` |
278
+ |---|---|---|
279
+ | `apps/storefront` | `@spree/sdk` | `store` |
280
+ | `apps/dashboard` | `@spree/admin-sdk` | `admin` |
281
+ | `apps/seller-dashboard` (marketplaces) | `@spree/seller-sdk` | `seller` |
282
+
283
+ **Spree CLI:**
284
+
285
+ ```bash
286
+ spree filters types --api store --out apps/storefront/src/types/spree-filters.d.ts
287
+ spree filters types --api admin --out apps/dashboard/src/types/spree-filters.d.ts
288
+ spree filters types --api seller --out apps/seller-dashboard/src/types/spree-filters.d.ts
289
+ ```
290
+
291
+ **Without CLI:**
292
+
293
+ ```bash
294
+ cd server
295
+ bin/rails spree:api:filter_tables > ../filter-tables.json
296
+ cd ..
297
+ npx @spree/cli filters types --from filter-tables.json --api store --out apps/storefront/src/types/spree-filters.d.ts
298
+ npx @spree/cli filters types --from filter-tables.json --api admin --out apps/dashboard/src/types/spree-filters.d.ts
299
+ npx @spree/cli filters types --from filter-tables.json --api seller --out apps/seller-dashboard/src/types/spree-filters.d.ts
300
+ ```
301
+
302
+
303
+ Run them again after you add or change a filter, and commit the generated files with the app code that uses them. Match `--api` to the app: a storefront file generated with `--api admin` compiles, but declares the Admin API's filters. After that, every `list()` call accepts and autocompletes your filters and sort fields, and a misspelled one is still a compile error.
304
+
305
+ **Send it unchecked.** Filters inside `q` are sent as given, without type checking or autocomplete:
306
+
307
+ ```typescript
308
+ await client.products.list({
309
+ name_cont: 'shirt',
310
+ q: { erp_id_eq: 'ERP-1' },
311
+ })
312
+ ```
313
+
314
+ **Declare it by hand.** Each resource has an empty interface in the SDK (`ProductFilterExtensions`, `OrderFilterExtensions`, …) that is part of its filter type, and TypeScript [merges your declaration](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) into it. The file must contain an `import` or `export`; without one, it replaces the SDK's types instead of adding to them:
315
+
316
+ ```typescript src/types/spree.d.ts
317
+ export {}
318
+
319
+ declare module '@spree/sdk' {
320
+ interface ProductFilterExtensions {
321
+ erp_id_eq?: string
322
+ featured?: boolean
323
+ }
324
+ // Sort fields, as keys
325
+ interface ProductSortExtensions {
326
+ erp_id: true
327
+ }
328
+ }
329
+ ```
330
+
331
+ Name each filter the way the API receives it, with its predicate (`erp_id_eq`, `erp_id_in` for a list) or a scope by its name. Type amounts as decimal strings, counts as numbers and flags as booleans.
332
+
248
333
  ## Pagination
249
334
 
250
335
  All list endpoints support pagination:
@@ -39,6 +39,137 @@ end
39
39
 
40
40
  Any model inheriting from `Spree.base_class` can declare preferences — the reader and writer methods come with it, so there is nothing to include. The values live in that model's `preferences` column.
41
41
 
42
+ ### Declaring the full type
43
+
44
+ A declaration is also the preference's contract in the API: Spree turns it into a JSON Schema that the API publishes, the SDKs type and every write is checked against. So a declaration states everything a client needs to know about the value:
45
+
46
+ ```ruby server/app/models/my_app/promotion/rules/loyalty_tier.rb
47
+ module MyApp
48
+ module Promotion
49
+ module Rules
50
+ class LoyaltyTier < Spree::PromotionRule
51
+ preference :minimum_spend, :money, default: 0
52
+ preference :tier, :string, choices: %w[silver gold platinum], default: 'silver'
53
+ preference :channel_ids, :array, of: :id, model: 'Spree::Channel', default: [],
54
+ scope: ->(rule) { rule.promotion.store.channels }
55
+ preference :country_codes, :array, of: :string, format: :iso_country, default: []
56
+ preference :bonus_amounts, :hash, keys: :currency, values: :money, default: {}
57
+ end
58
+ end
59
+ end
60
+ end
61
+ ```
62
+
63
+ | Option | Applies to | What it says |
64
+ |---|---|---|
65
+ | `of:` | `:array` | The type of each item: `:string`, `:integer`, `:decimal`, `:money`, `:boolean`, `:id` or `:object` |
66
+ | `properties:` | `of: :object` | The fields of each item, e.g. `{ threshold: :money, value: :decimal }` |
67
+ | `keys:` / `values:` | `:hash` | The type of the keys (`:string` or `:currency`) and of the values |
68
+ | `model:` / `scope:` | `of: :id` | The records the ids point at, and the records they must be found in. The API accepts and returns their prefixed ids (`ch_…`); an id of another model, or one outside the scope, is refused |
69
+ | `format:` | `:string`, list items | `:currency`, `:iso_country`, `:timezone`, `:locale`, `:url` or `:color` |
70
+ | `choices:` | any | The fixed set the value must come from; admin forms show a picker |
71
+
72
+ Decimals and amounts travel as exact strings (`"9.99"`), never as numbers. An `:array` without `of:`, a `:hash` without `keys:` and `values:`, or the `:any` type still works in Spree 6.0, with a deprecation warning and a schema that accepts any value. Spree 6.1 refuses them.
73
+
74
+ ### Preferences in the API
75
+
76
+ Configurable types — promotion rules and actions, calculators, payment methods, integrations, and price, delivery, commission and order routing rules — send their settings as a `preferences` object next to their `type`. Each family's `/types` endpoint returns the JSON Schema of every registered type's preferences, extensions included, so a client can render a form for a type it has never seen:
77
+
78
+
79
+ ```typescript Admin SDK
80
+ const { data } = await adminClient.promotionRules.types()
81
+ const itemTotal = data.find((entry) => entry.type === 'item_total')
82
+ itemTotal.schema.properties.amount_min // { type: 'string', format: 'money', default: '100', … }
83
+ ```
84
+
85
+ ```bash cURL
86
+ curl 'https://api.mystore.com/api/v3/admin/promotion_rules/types' \
87
+ -H 'X-Spree-API-Key: sk_xxx'
88
+ ```
89
+
90
+
91
+ The SDKs also type the settings of every type Spree ships. `TypedPromotionRule` narrows `preferences` by `type`, and an extension adds its own types to `PromotionRulePreferencesMap` by declaration merging.
92
+
93
+ A write is checked against the schema before anything is saved. An unknown key, a value of the wrong type, a value outside `choices`, or an id of the wrong model or another store is refused with a `422` whose `code` is `invalid_preferences`; `details` names each failing value by its JSON pointer, such as `/preferences/channel_ids/1`. A secret reads back masked; sending the masked value back keeps the stored secret, and `null` clears it.
94
+
95
+ ### Exposing a setting in the API
96
+
97
+ Use this when a record has a fixed set of settings that clients edit field by field, as the store, a channel or an import do. A family of types with different settings per type (rules, calculators, payment methods) sends a `preferences` object instead, as described above.
98
+
99
+ The API names such a setting by its plain name (`featured`), never by the `preferred_` method. It takes three declarations, one per layer, and each layer stays in charge of its own job.
100
+
101
+ **1. Expose it on the model.** `exposes_preferences` gives each preference a plain reader, writer and predicate:
102
+
103
+ ```ruby server/app/models/spree/brand.rb
104
+ module Spree
105
+ class Brand < Spree.base_class
106
+ preference :featured, :boolean, default: false
107
+ preference :display_name, :string
108
+
109
+ exposes_preferences :featured, :display_name
110
+ end
111
+ end
112
+ ```
113
+
114
+ ```ruby
115
+ brand.featured # same as brand.preferred_featured
116
+ brand.featured = true # same as brand.preferred_featured = true
117
+ brand.featured? # true
118
+ ```
119
+
120
+ It refuses a name the model already uses for something else. Errors on `preferred_featured` are reported to API clients as `featured`.
121
+
122
+ **2. Return it from the serializer.** `preference_attributes` adds every exposed preference, typed from its declaration, so the generated SDK types follow the model. Name some to add only those, or pass `except:` to leave some out:
123
+
124
+ ```ruby server/app/serializers/spree/api/v3/admin/brand_serializer.rb
125
+ module Spree
126
+ module Api
127
+ module V3
128
+ module Admin
129
+ class BrandSerializer < Spree::Api::V3::BaseSerializer
130
+ attributes :name, created_at: :iso8601, updated_at: :iso8601
131
+
132
+ preference_attributes Spree::Brand
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ ```
139
+
140
+ **3. Accept it in the controller.** Exposing a setting does not make it writable: the controller decides, as for any attribute (see [permitted attributes](api.md#permitted-attributes)):
141
+
142
+ ```ruby server/app/controllers/spree/api/v3/admin/brands_controller.rb
143
+ def resource_permitted_attributes
144
+ [:name, :featured, :display_name]
145
+ end
146
+ ```
147
+
148
+ The setting is then read and written like a column:
149
+
150
+ **Admin SDK:**
151
+
152
+ ```typescript
153
+ const brand = await adminClient.request('PATCH', `/brands/${brandId}`, {
154
+ body: { featured: true, display_name: 'Wilson' },
155
+ })
156
+ brand.featured // true
157
+ ```
158
+
159
+ **cURL:**
160
+
161
+ ```bash
162
+ curl -X PATCH 'https://api.mystore.com/api/v3/admin/brands/brand_xxx' \
163
+ -H 'X-Spree-API-Key: sk_xxx' \
164
+ -H 'Content-Type: application/json' \
165
+ -d '{ "featured": true, "display_name": "Wilson" }'
166
+ ```
167
+
168
+
169
+ > **NOTE:** The Seller API keeps its own list of what sellers may write, so exposing a setting to operators never makes it writable by sellers.
170
+
171
+ Strings, integers and booleans are cast the way Rails casts attributes (`"1"`, `"true"` and `"on"` are true; `"42"` is 42). A blank value is `nil` for a `nullable: true` preference, and the type's empty value otherwise.
172
+
42
173
  ## Accessing Model Preferences
43
174
 
44
175
  Once preferences have been defined for a model, they can be accessed either using the shortcut methods that are generated for each preference or the generic methods that are not specific to a particular preference.
@@ -146,4 +277,3 @@ Around fifty models use preferences, and the pattern is always the same: a famil
146
277
  | `Spree::Store`, `Spree::Channel` and `Spree::Market` | Merchant settings — timezone, units, currency behaviour |
147
278
 
148
279
  This is why preferences exist rather than columns: `Spree::Calculator::FlatRate` and `Spree::Calculator::TieredPercent` are rows in one table with entirely different settings, and neither wants a column the other leaves null.
149
- * `Spree::Theme`
@@ -73,7 +73,7 @@ dashboard under **Settings → Store**, or through the Admin API:
73
73
 
74
74
  ```typescript Admin SDK
75
75
  await adminClient.store.update({
76
- preferred_address_requires_phone: false,
76
+ address_requires_phone: false,
77
77
  })
78
78
  ```
79
79
 
@@ -81,7 +81,7 @@ await adminClient.store.update({
81
81
  curl -X PATCH 'https://api.mystore.com/api/v3/admin/store' \
82
82
  -H 'X-Spree-API-Key: sk_xxx' \
83
83
  -H 'Content-Type: application/json' \
84
- -d '{ "preferred_address_requires_phone": false }'
84
+ -d '{ "address_requires_phone": false }'
85
85
  ```
86
86
 
87
87
 
@@ -467,14 +467,14 @@ The gate is enforced by the Store API, not by the storefront, so a storefront ap
467
467
  ```typescript Admin SDK
468
468
  // Gate the wholesale channel, and give its shoppers a default agreement
469
469
  await adminClient.channels.update('ch_xxx', {
470
- preferred_storefront_access: 'login_required',
471
- preferred_guest_checkout: false,
470
+ storefront_access: 'login_required',
471
+ guest_checkout: false,
472
472
  default_catalog_id: 'cat_xxx',
473
473
  })
474
474
 
475
475
  // Or set the fallback for every channel that does not set its own
476
476
  await adminClient.store.update({
477
- preferred_storefront_access: 'prices_hidden',
477
+ storefront_access: 'prices_hidden',
478
478
  })
479
479
  ```
480
480
 
@@ -483,8 +483,8 @@ curl -X PATCH 'https://api.mystore.com/api/v3/admin/channels/ch_xxx' \
483
483
  -H 'X-Spree-API-Key: sk_xxx' \
484
484
  -H 'Content-Type: application/json' \
485
485
  -d '{
486
- "preferred_storefront_access": "login_required",
487
- "preferred_guest_checkout": false,
486
+ "storefront_access": "login_required",
487
+ "guest_checkout": false,
488
488
  "default_catalog_id": "cat_xxx"
489
489
  }'
490
490
  ```
@@ -50,15 +50,15 @@ Three store preferences decide how sellers get paid. All three are writable thro
50
50
 
51
51
  | Preference | Default | What it decides |
52
52
  |---|---|---|
53
- | `preferred_payout_provider` | blank | Who moves the money. Blank means core's record-only provider: the ledger is kept correctly and the operator settles by hand. |
54
- | `preferred_default_payouts_schedule_interval` | `monthly` | How often sellers are settled. One of `daily`, `weekly`, `biweekly`, `monthly`, `manual`. A seller can carry its own interval, which wins. |
55
- | `preferred_default_minimum_payout_amount` | `0` | What a seller's balance must reach before a settlement is worth sending. Below it the balance carries to the next period. |
53
+ | `payout_provider` | blank | Who moves the money. Blank means core's record-only provider: the ledger is kept correctly and the operator settles by hand. |
54
+ | `default_payouts_schedule_interval` | `monthly` | How often sellers are settled. One of `daily`, `weekly`, `biweekly`, `monthly`, `manual`. A seller can carry its own interval, which wins. |
55
+ | `default_minimum_payout_amount` | `0` | What a seller's balance must reach before a settlement is worth sending. Below it the balance carries to the next period. |
56
56
 
57
57
 
58
58
  ```typescript Admin SDK
59
59
  await adminClient.store.update({
60
- preferred_default_payouts_schedule_interval: 'weekly',
61
- preferred_default_minimum_payout_amount: 25,
60
+ default_payouts_schedule_interval: 'weekly',
61
+ default_minimum_payout_amount: '25.00',
62
62
  })
63
63
  ```
64
64
 
@@ -67,8 +67,8 @@ curl -X PATCH 'https://api.mystore.com/api/v3/admin/store' \
67
67
  -H 'X-Spree-API-Key: sk_xxx' \
68
68
  -H 'Content-Type: application/json' \
69
69
  -d '{
70
- "preferred_default_payouts_schedule_interval": "weekly",
71
- "preferred_default_minimum_payout_amount": 25
70
+ "default_payouts_schedule_interval": "weekly",
71
+ "default_minimum_payout_amount": "25.00"
72
72
  }'
73
73
  ```
74
74
 
@@ -88,18 +88,18 @@ Admin API like the payout ones.
88
88
 
89
89
  | Preference | Default | What it decides |
90
90
  |---|---|---|
91
- | `preferred_auto_approve_sellers` | `false` | Admit a seller the moment they finish the checklist, with nobody looking at them. |
92
- | `preferred_auto_approve_seller_products` | `false` | Put a seller's product on sale the moment they submit it. |
93
- | `preferred_send_seller_transactional_emails` | `true` | Whether Spree emails sellers. Turn it off if you front seller communications yourself. |
94
- | `preferred_default_commission_tax_rate` | `0` | Tax on your commission when neither the rate nor the tax provider names one. |
91
+ | `auto_approve_sellers` | `false` | Admit a seller the moment they finish the checklist, with nobody looking at them. |
92
+ | `auto_approve_seller_products` | `false` | Put a seller's product on sale the moment they submit it. |
93
+ | `send_seller_transactional_emails` | `true` | Whether Spree emails sellers. Turn it off if you front seller communications yourself. |
94
+ | `default_commission_tax_rate` | `0` | Tax on your commission when neither the rate nor the tax provider names one. |
95
95
 
96
96
 
97
97
  ```typescript Admin SDK
98
98
  await adminClient.store.update({
99
- preferred_auto_approve_sellers: false,
100
- preferred_auto_approve_seller_products: false,
101
- preferred_send_seller_transactional_emails: true,
102
- preferred_default_commission_tax_rate: 0.23,
99
+ auto_approve_sellers: false,
100
+ auto_approve_seller_products: false,
101
+ send_seller_transactional_emails: true,
102
+ default_commission_tax_rate: '0.23',
103
103
  })
104
104
  ```
105
105
 
@@ -108,10 +108,10 @@ curl -X PATCH 'https://api.mystore.com/api/v3/admin/store' \
108
108
  -H 'X-Spree-API-Key: sk_xxx' \
109
109
  -H 'Content-Type: application/json' \
110
110
  -d '{
111
- "preferred_auto_approve_sellers": false,
112
- "preferred_auto_approve_seller_products": false,
113
- "preferred_send_seller_transactional_emails": true,
114
- "preferred_default_commission_tax_rate": 0.23
111
+ "auto_approve_sellers": false,
112
+ "auto_approve_seller_products": false,
113
+ "send_seller_transactional_emails": true,
114
+ "default_commission_tax_rate": "0.23"
115
115
  }'
116
116
  ```
117
117
 
@@ -402,7 +402,7 @@ curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx/reject' \
402
402
 
403
403
  Filter the review queue with a Ransack query on status: `adminClient.products.list({ status_eq: 'proposed' })`.
404
404
 
405
- With `preferred_auto_approve_seller_products` on, submitting chains straight into approval. The submission row still gets written and carries an `auto_approved` marker, so a blank reviewer reads as "this store does not review listings" rather than as a lost name.
405
+ With `auto_approve_seller_products` on, submitting chains straight into approval. The submission row still gets written and carries an `auto_approved` marker, so a blank reviewer reads as "this store does not review listings" rather than as a lost name.
406
406
 
407
407
  There is no bulk route onto `active` for sellers, and that is deliberate: reaching it is the operator's decision on one listing at a time.
408
408
 
@@ -467,7 +467,7 @@ curl 'https://api.mystore.com/api/v3/admin/commission_lines?q[seller_id_eq]=sel_
467
467
 
468
468
  There is no write path. Correcting a charge is a reversal, not an edit.
469
469
 
470
- In the EU the fee is a separate supply from the sale, so it is taxed separately — that is what `preferred_default_commission_tax_rate` from step 1 is for, and a rate or a tax provider can name its own.
470
+ In the EU the fee is a separate supply from the sale, so it is taxed separately — that is what `default_commission_tax_rate` from step 1 is for, and a rate or a tax provider can name its own.
471
471
 
472
472
  **Read more:** [Commissions](../core-concepts/commissions.md) covers the four rule types, gross-versus-net, how delivery is treated, currency floors and caps, and commission tax in full. [Commission rates](/user/sellers/commission-rates) is the operator's screen.
473
473
 
@@ -584,7 +584,7 @@ curl -X POST 'https://api.mystore.com/api/v3/admin/sellers/sel_xxx/payouts' \
584
584
 
585
585
  ### Payout providers
586
586
 
587
- A provider is a stateless class registered in `Spree.payout_providers` and chosen per store with `preferred_payout_provider`.
587
+ A provider is a stateless class registered in `Spree.payout_providers` and chosen per store with `payout_provider`.
588
588
 
589
589
  | Provider | Moves money? | Completion |
590
590
  |---|---|---|
@@ -246,7 +246,7 @@ Activate on the whole store:
246
246
 
247
247
  ```ruby
248
248
  Spree::Store.default.update!(
249
- preferred_order_routing_strategy: 'Acme::Oms::Strategy'
249
+ order_routing_strategy: 'Acme::Oms::Strategy'
250
250
  )
251
251
  ```
252
252
 
@@ -255,11 +255,11 @@ Override on one channel only:
255
255
  ```ruby
256
256
  store = Spree::Store.default
257
257
  store.channels.find_by(code: 'pos').update!(
258
- preferred_order_routing_strategy: 'Acme::Oms::Strategy'
258
+ order_routing_strategy: 'Acme::Oms::Strategy'
259
259
  )
260
260
  ```
261
261
 
262
- Resolution order: `channel.preferred_order_routing_strategy` → `store.preferred_order_routing_strategy` → the default `Strategy::Rules`. Only a value pointing to a **registered** `Strategy::Base` subclass is used; anything unset, unregistered, or invalid is skipped, so a misconfiguration (or a strategy you've since unregistered) falls back to the default instead of breaking checkout.
262
+ Resolution order: the channel's `order_routing_strategy` → the store's `order_routing_strategy` → the default `Strategy::Rules`. Only a value pointing to a **registered** `Strategy::Base` subclass is used; anything unset, unregistered, or invalid is skipped, so a misconfiguration (or a strategy you've since unregistered) falls back to the default instead of breaking checkout.
263
263
 
264
264
  ### Step 3: Test the Strategy
265
265
 
@@ -169,7 +169,7 @@ attribute(:brand_id) { |product| product.brand&.prefixed_id }
169
169
  has_one :brand, serializer: Spree::Api::V3::BrandSerializer
170
170
  ```
171
171
 
172
- The model has to allow filtering by it, or `?q[brand_id_eq]=…` is rejected:
172
+ The model has to allow filtering by it, or `?q[brand_id_eq]=…` is ignored:
173
173
 
174
174
  ```ruby server/app/models/spree/product_decorator.rb
175
175
  base.whitelisted_ransackable_attributes |= %w[brand_id]
@@ -506,7 +506,34 @@ If your storefront or webhook receiver reads any of these fields, compare agains
506
506
  | `payment_source_type` | Store API payment setup sessions, `payment_setup_session.*` webhooks | `Spree::CreditCard` | `credit_card` |
507
507
  | `type` | Custom fields on products, variants, categories and collections | `Spree::CustomFields::ShortText` | removed — read `field_type` (`short_text`) |
508
508
 
509
- The Admin API follows the same rule everywhere: providers and strategies (`fulfillment_provider: 'manual'`, `preferred_order_routing_strategy: 'rules'`), custom field definition `resource_type` (`product`, `category`), tag `taggable_type`, the owner and originator types on records, the permission subjects `/me` returns (`product`, `category`), and filters such as `q[type_eq]=orders`. An integration written against the Admin API preview sends and expects these short names; the [Admin SDK](../sdk/admin/resources.md) types list them.
509
+ The Admin API follows the same rule everywhere: providers and strategies (`fulfillment_provider: 'manual'`, `order_routing_strategy: 'rules'`), custom field definition `resource_type` (`product`, `category`), tag `taggable_type`, the owner and originator types on records, the permission subjects `/me` returns (`product`, `category`), and filters such as `q[type_eq]=orders`. An integration written against the Admin API preview sends and expects these short names; the [Admin SDK](../sdk/admin/resources.md) types list them.
510
+
511
+ ## Preferences are typed in the API
512
+
513
+ Settings no longer reach the Admin and Seller APIs in the shape of Ruby's preference methods. The Store API is not affected.
514
+
515
+ **Settings use plain names.** Every `preferred_*` field is renamed, on read and on write:
516
+
517
+ | Resource | Before | After |
518
+ |---|---|---|
519
+ | Store (`/admin/store`) | `preferred_timezone`, `preferred_guest_checkout`, `preferred_storefront_url`, … every `preferred_*` field | `timezone`, `guest_checkout`, `storefront_url`, … the same name without the prefix |
520
+ | Channel | `preferred_storefront_access`, `preferred_guest_checkout`, `preferred_order_routing_strategy` | `storefront_access`, `guest_checkout`, `order_routing_strategy` |
521
+ | Import (Admin and Seller) | `preferred_delimiter` | `delimiter` |
522
+ | Seller profile | `preferred_timezone` | `timezone` |
523
+
524
+ `order.preferred_stock_location_id` keeps its name: it is an association, not a setting. Validation errors name the field without the prefix too. Writes still accept the old `preferred_*` names until Spree 6.1, with a deprecation warning in the server log; responses only carry the new names. The store's `storefront_url` is the setting as saved, `null` when unset; `url` is the address customer links use. The store's `default_minimum_payout_amount` and `default_commission_tax_rate` are now exact decimal strings (`"0.23"`) instead of numbers.
525
+
526
+ **`preference_schema` is replaced by `schema`.** Rules, actions, payment methods and integrations no longer carry `preference_schema` on every row. Each family's `/types` endpoint (and the delivery-method and promotion-action calculator lists) returns a `schema` per type instead: the JSON Schema of its `preferences`, with `format`, `enum`, `default` and `x-spree-secret`. Read a row's type, then look up its schema there. `/payment_methods/types` now lists every provider, with `installed: true` on those the store already has, instead of leaving them out.
527
+
528
+ **Writes are validated.** A `preferences` payload with an unknown key, a value of the wrong type, a value outside the declared choices, or an id of another model or another store is refused with a `422` whose `code` is `invalid_preferences`. `details` names each failing value by its JSON pointer (`/preferences/channel_ids/1`, or `/rules/0/preferences/channel_ids/1` inside a list of rules). Before, unknown keys were silently dropped. Send decimals as strings (`"10.0"`) and record ids as prefixed ids (`ch_…`).
529
+
530
+ **Id lists read as prefixed ids.** Preferences listing records, such as a rule's `channel_ids` or `customer_group_ids`, now return prefixed ids rather than database ids, the same form a write accepts.
531
+
532
+ **Delivery methods nest their calculator.** `calculator_type` and `calculator_preferences` are replaced by `calculator: { type, preferences }` on read and write, the shape promotion actions already use. Writes still accept the old two parameters until Spree 6.1, with a deprecation warning; their values follow the new rules (decimals as strings).
533
+
534
+ A preference marked deprecated, such as a flat rate calculator's `minimum_item_total`, is still accepted on write until its removal, with its deprecation warning, but is not read back.
535
+
536
+ **For extension authors:** declare every preference's full type (`of:` on arrays, `keys:` and `values:` on hashes, the `:money` type for amounts, `choices:`, `format:`). An incomplete declaration still works in 6.0 with a deprecation warning, and its schema accepts any value. Replace `normalize_id_preference` with `preference :ids, :array, of: :id, model:, scope:`, and `in:` with `choices:`. See [Model preferences](../customization/model-preferences.md#declaring-the-full-type).
510
537
 
511
538
  ## Every event is declared
512
539
 
@@ -582,7 +609,7 @@ Saving or touching a product used to bump the `updated_at` of each of its catego
582
609
 
583
610
  `Spree::OrderRouting::Strategy::Legacy` — the pre-5.5 escape hatch that delegated straight to `Spree::Stock::Coordinator` and consulted no routing rules — is removed and no longer registered in `Spree.order_routing.strategies`.
584
611
 
585
- A store or channel still carrying `preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy'` **keeps working**: `Order#order_routing_strategy` ignores unregistered classes, logs a warning, and falls back to `Strategy::Rules`. Clear the stale preference to silence the warning — note that saving such a record now fails validation, since the value is no longer in the registry.
612
+ A store or channel still carrying `order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy'` **keeps working**: `Order#order_routing_strategy` ignores unregistered classes, logs a warning, and falls back to `Strategy::Rules`. Clear the stale preference to silence the warning — note that saving such a record now fails validation, since the value is no longer in the registry.
586
613
 
587
614
  `Spree::Stock::Coordinator` itself stays — cart fulfillment building, exchanges, and claims still use it.
588
615
 
@@ -800,7 +827,7 @@ Other changes that come with it:
800
827
 
801
828
  - **Numbers are no longer parsed by locale.** Price, variant, payment, refund, store credit, gift card, discount and fee amounts take a number or a canonical decimal string (`"1234.56"`); any other text raises `Spree::Money::InvalidFormat`, which the API answers with `invalid_money_format`. Before 6.0 they were re-read under the request's language, so a store whose language writes a comma decimal (Dutch, German, French) saved the dashboard's `49.50` as 4950, and an unchanged price grew tenfold on each save. Send canonical decimal strings (`"1234.56"`) and convert localized input in the client. `Spree::LocalizedNumber` still works with a warning until 6.1. If your prices were saved in a comma-decimal store before upgrading, check them.
802
829
  - **Rounding follows the currency.** Taxes, percentage discounts, promotion splits and shipping markups round to the currency's own decimal places from the ISO 4217 table: whole yen, three decimals for dinar. Two-decimal currencies are unchanged. Open carts in other currencies may change by less than one minor unit the next time they are recalculated. Placed orders do not change. Spree now gives the Hungarian forint two decimal places, as ISO 4217 does.
803
- - **Money on the API is written to each currency's decimals.** Every amount in the Store, Admin and Seller APIs, and so in webhook payloads and email variables, is a decimal string with exactly its currency's decimal places: `"10.00"` (was `"10.0"`), `"1000"` in yen (was `"1000.0"`), `"1.500"` in dinar. Unit prices keep up to four decimals. Fields that were JSON numbers are now strings: `cart.order_minimum` and `order_minimum_shortfall`, the product filter price range `min`/`max`, report money metrics and percentages, and the store's `preferred_default_minimum_payout_amount` and `preferred_default_commission_tax_rate`. Clients comparing these strings, or parsing them as numbers, should switch to exact decimal arithmetic; the SDKs export `sumMoney`, `subtractMoney`, `multiplyMoney`, `compareMoney` and `isZeroMoney`.
830
+ - **Money on the API is written to each currency's decimals.** Every amount in the Store, Admin and Seller APIs, and so in webhook payloads and email variables, is a decimal string with exactly its currency's decimal places: `"10.00"` (was `"10.0"`), `"1000"` in yen (was `"1000.0"`), `"1.500"` in dinar. Unit prices keep up to four decimals. Fields that were JSON numbers are now strings: `cart.order_minimum` and `order_minimum_shortfall`, the product filter price range `min`/`max`, report money metrics and percentages, and the store's `default_minimum_payout_amount` and `default_commission_tax_rate`. Clients comparing these strings, or parsing them as numbers, should switch to exact decimal arithmetic; the SDKs export `sumMoney`, `subtractMoney`, `multiplyMoney`, `compareMoney` and `isZeroMoney`.
804
831
  - **Admin and Seller writes refuse JSON numbers for money.** Amounts and rates, including those inside `preferences`, must be canonical decimal strings (`"19.99"`). A number, a localized or grouped string, or more decimals than the field holds is answered with `422` and `invalid_money_format`, naming the field.
805
832
  - **Amounts may not carry more decimals than they can keep.** Now that the columns hold four decimals, the database no longer rounds away a fraction of a cent, so a payment, refund, store credit, gift card, fee or discount with more decimals than its currency has (`10.0049` dollars) fails validation with `too_many_decimals`. Unit prices and cost prices allow four, tax rates five decimals of the fraction (`rate_percent` `"7.125"`, not `"7.1255"`), and price list percentages three. Round in your own code before saving. A manual order discount's `value` and a commission rate's `value` are read as strictly as other money fields.
806
833
  - **The Admin and Seller APIs no longer return `display_*` money fields.** Responses carry the amount (`"29.99"`) with its currency, and the client formats it in the reader's own language. Before 6.0 the server formatted a copy in the request's language, which could disagree with the editable amount beside it. Format with `Intl.NumberFormat` or your platform's equivalent. The Store API, webhook payloads and email templates keep their `display_*` fields, and labels such as `display_name` are unchanged.
@@ -851,6 +878,9 @@ Before 6.0 a cart was an incomplete order, so extensions written for 5.x (paymen
851
878
  | `Address#firstname`, `#lastname`, `#zipcode` | `#first_name`, `#last_name`, `#postal_code` (columns renamed) |
852
879
  | `Address.normalize_zipcode`, `#normalized_zipcode` | `.normalize_postal_code`, `#normalized_postal_code` |
853
880
  | `Order#bill_address_firstname`, `#bill_address_lastname` | `#bill_address_first_name`, `#bill_address_last_name` |
881
+ | `preference … parse_on_set: normalize_id_preference(klass:, scope:)` | `preference …, :array, of: :id, model:, scope:` |
882
+ | `preference …, in: [...]` | `preference …, choices: [...]` |
883
+ | `:array` without `of:`, `:hash` without `keys:`/`values:`, the `:any` type | A complete declaration (see [Model preferences](../customization/model-preferences.md#declaring-the-full-type)) |
854
884
  | `OptionType#presentation`, `OptionValue#presentation` | `#label` (column renamed, translations included). Reads and writes on an instance only — `where(presentation:)` and `find_by(presentation:)` raise, because Mobility owns `label` so the bridge cannot be an attribute alias. Query by `label`. |
855
885
  | `OptionValue#option_type_presentation` | `#option_type_label` |
856
886
  | `q[presentation_cont]` (option type / value filters) | `q[label_cont]` — the Ransack whitelist publishes the column, so the filter key moves with it |
@@ -962,6 +992,55 @@ Both internal-note serializers now return the pair. Previously orders exposed on
962
992
 
963
993
  Stored markup is also held to a narrower allowlist than 5.6 — see [the rich-text migration](#move-rich-text-out-of-action-text) for what is permitted and how to widen it.
964
994
 
995
+ ## Upgrading a storefront
996
+
997
+ A storefront built on the Store API and `@spree/sdk`, such as the [Spree storefront](https://github.com/spree/storefront), needs `@spree/sdk` 2.0 and the changes below. Most are type errors that point at the line to change.
998
+
999
+ ### List filters and sort are typed
1000
+
1001
+ Each `list()` method now takes the filters and sort fields its endpoint accepts. A misspelled filter, a filter the endpoint doesn't support, or an unknown sort field used to return the unfiltered list; it is now a compile error. `ProductListParams`, `CategoryListParams`, `CollectionListParams` and `OrderListParams` keep their names but no longer accept arbitrary keys.
1002
+
1003
+ - **A sort value read from the URL is a plain `string`.** Narrow it where you read it; the API still validates the value at runtime:
1004
+
1005
+ ```ts src/lib/utils/product-query.ts
1006
+ import type { ProductListParams, ProductSort } from '@spree/sdk'
1007
+
1008
+ params.sort = filters.sortBy as ProductSort
1009
+ ```
1010
+
1011
+ - **Filters your backend adds are compile errors until you declare them.** The published types list only the filters Spree itself declares. Any filter or scope your backend adds (through `Spree.ransack.add_attribute`, `add_scope` or a model decorator) still works in the API, but TypeScript rejects the key. Generate the declarations from your app, and run the command again whenever a filter changes:
1012
+
1013
+ ```bash
1014
+ spree filters types --api store --out apps/storefront/src/types/spree-filters.d.ts
1015
+ ```
1016
+
1017
+ Generate one for the dashboard (`--api admin --out apps/dashboard/src/types/spree-filters.d.ts`) and the seller panel (`--api seller`) too, if they use your filters. For a one-off filter, send it inside `q` instead (`list({ q: { erp_id_eq: 'ERP-1' } })`), which is not type checked. See [using your own filters from TypeScript](../core-concepts/search-filtering.md#using-your-own-filters-from-typescript) for both, and for declaring filters by hand.
1018
+
1019
+ - **Amount filters take decimal strings.** `price_gte`, `price_lte` and the other money filters are typed as strings, like every money value in 6.0. Send `'20.00'`, not `20`.
1020
+
1021
+ - **Prefer `search`.** Products, categories and collections take `q[search]` (the SDK's `search` key), which matches more than `name_cont`. See [which filters an endpoint accepts](../../api-reference/store-api/querying.md#which-filters-an-endpoint-accepts).
1022
+
1023
+ ### Completing a cart can return an order group
1024
+
1025
+ `carts.complete()` returns `Order | OrderGroup`. In a marketplace, a cart holding several sellers' goods divides into one order per seller and returns the group. Narrow the result with `isOrderGroup` before reading order fields:
1026
+
1027
+ ```ts
1028
+ import { isOrderGroup } from '@spree/sdk'
1029
+
1030
+ const result = await client.carts.complete(cartId)
1031
+ const orders = isOrderGroup(result) ? result.orders : [result]
1032
+ ```
1033
+
1034
+ ### Other Store API changes to check
1035
+
1036
+ | Change | What to update | Details |
1037
+ |---|---|---|
1038
+ | Delivery requirement renamed | Match `delivery_method` (field) and `delivery_method_required` (code) instead of `shipping_method` | [Checkout without a state machine](#checkout-without-a-state-machine) |
1039
+ | Type values are short names | Compare `payment_source_type` with `credit_card`, and read custom field `field_type` | [Type values are short names](#type-values-are-short-names-not-class-names) |
1040
+ | Rich text comes in two shapes | Render `description_html` for markup; `description` is plain text | [Rich-text fields](#rich-text-fields-read-as-plain-text-plus-html) |
1041
+ | Payment source IDs | Stored `source_id` values change prefix from `ps_` to `psrc_` | [Payment source IDs](#payment-source-ids-use-the-psrc_-prefix) |
1042
+ | Completed carts are read-only | Read post-checkout data from the order, not the cart | [The Cart/Order split](#the-cartorder-split) |
1043
+
965
1044
  ## For extension authors
966
1045
 
967
1046
  - **Don't reach for model business methods from services** — 6.0 code style writes behavior inline in service/workflow steps; models keep data, validations, predicates and persistence primitives. Extensions patching removed model methods (`finalize!` internals, updater hooks) should move to workflow hooks (`Spree.hooks.register('carts.complete.before_finalize') { |flow| ... }` — handlers receive the workflow instance) or event subscribers.