@spree/docs 0.1.248 → 0.1.249

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 (76) hide show
  1. package/dist/api-reference/admin-api/authentication.md +34 -14
  2. package/dist/api-reference/admin-api/endpoints.md +366 -14
  3. package/dist/api-reference/admin-api/errors.md +2 -2
  4. package/dist/api-reference/admin-api/introduction.md +3 -3
  5. package/dist/api-reference/admin-api/querying.md +6 -6
  6. package/dist/api-reference/store-api/monetary-amounts.md +5 -5
  7. package/dist/api-reference/webhooks-events.md +330 -335
  8. package/dist/developer/agentic/agent-skills.md +5 -2
  9. package/dist/developer/agentic/llm-docs.md +2 -1
  10. package/dist/developer/cli/admin-api.md +1 -1
  11. package/dist/developer/cli/quickstart.md +2 -2
  12. package/dist/developer/contributing/creating-an-extension.md +292 -146
  13. package/dist/developer/contributing/developing-spree.md +13 -17
  14. package/dist/developer/core-concepts/catalogs.md +2 -2
  15. package/dist/developer/core-concepts/channels.md +3 -3
  16. package/dist/developer/core-concepts/companies.md +2 -1
  17. package/dist/developer/core-concepts/delivery-setup.md +2 -2
  18. package/dist/developer/core-concepts/discounts.md +3 -3
  19. package/dist/developer/core-concepts/events.md +6 -5
  20. package/dist/developer/core-concepts/freight.md +3 -2
  21. package/dist/developer/core-concepts/fulfillments.md +10 -8
  22. package/dist/developer/core-concepts/imports-exports.md +11 -8
  23. package/dist/developer/core-concepts/inventory.md +2 -2
  24. package/dist/developer/core-concepts/media.md +14 -14
  25. package/dist/developer/core-concepts/orders.md +2 -2
  26. package/dist/developer/core-concepts/payments.md +1 -2
  27. package/dist/developer/core-concepts/products.md +6 -6
  28. package/dist/developer/core-concepts/reporting.md +4 -3
  29. package/dist/developer/core-concepts/returns-exchanges-claims.md +7 -7
  30. package/dist/developer/core-concepts/search-filtering.md +3 -3
  31. package/dist/developer/core-concepts/sellers.md +3 -3
  32. package/dist/developer/core-concepts/staff-roles.md +4 -2
  33. package/dist/developer/core-concepts/store-credits-gift-cards.md +1 -1
  34. package/dist/developer/core-concepts/stores.md +2 -2
  35. package/dist/developer/core-concepts/translations.md +12 -8
  36. package/dist/developer/core-concepts/webhooks.md +19 -18
  37. package/dist/developer/create-spree-app/quickstart.md +2 -7
  38. package/dist/developer/customization/api.md +1 -1
  39. package/dist/developer/customization/checkout.md +2 -2
  40. package/dist/developer/customization/dependencies.md +53 -37
  41. package/dist/developer/customization/permissions.md +2 -2
  42. package/dist/developer/dashboard/concepts.md +1 -1
  43. package/dist/developer/dashboard/customization/navigation.md +3 -2
  44. package/dist/developer/dashboard/customization/permissions.md +6 -6
  45. package/dist/developer/dashboard/plugins/publishing.md +4 -4
  46. package/dist/developer/dashboard/plugins/scaffolding.md +1 -1
  47. package/dist/developer/dashboard/public-api.md +1 -1
  48. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +3 -18
  49. package/dist/developer/deployment/aws.md +1 -1
  50. package/dist/developer/deployment/aws_ecs.md +3 -3
  51. package/dist/developer/deployment/background_jobs.md +9 -3
  52. package/dist/developer/deployment/docker.md +1 -2
  53. package/dist/developer/deployment/emails.md +3 -1
  54. package/dist/developer/deployment/environment_variables.md +2 -2
  55. package/dist/developer/deployment/render.md +2 -2
  56. package/dist/developer/how-to/build-a-marketplace.md +2 -2
  57. package/dist/developer/how-to/custom-api-authentication.md +1 -1
  58. package/dist/developer/how-to/custom-delivery-rate-provider.md +11 -3
  59. package/dist/developer/how-to/custom-document-numbers.md +1 -1
  60. package/dist/developer/how-to/custom-order-routing.md +15 -14
  61. package/dist/developer/how-to/custom-payment-method.md +17 -19
  62. package/dist/developer/how-to/custom-promotion.md +4 -4
  63. package/dist/developer/how-to/custom-search-provider.md +12 -5
  64. package/dist/developer/how-to/custom-stock-splitter.md +25 -24
  65. package/dist/developer/how-to/sell-digital-products.md +1 -1
  66. package/dist/developer/multi-tenant/quickstart.md +2 -2
  67. package/dist/developer/providers/payouts.md +6 -2
  68. package/dist/developer/sdk/admin/querying-and-errors.md +1 -1
  69. package/dist/developer/sdk/admin/quickstart.md +4 -4
  70. package/dist/developer/sdk/authentication.md +5 -2
  71. package/dist/developer/sdk/store/cart-checkout.md +4 -4
  72. package/dist/developer/storefront/nextjs/emails.md +4 -2
  73. package/dist/developer/storefront/nextjs/testing.md +1 -1
  74. package/dist/developer/upgrades/5.6-to-6.0.md +51 -20
  75. package/dist/integrations/search/meilisearch.md +4 -4
  76. package/package.json +1 -1
@@ -33,7 +33,7 @@ Four small pieces — each one unlocks a specific frontend capability. In a host
33
33
  **The column** (with an index — you're adding it *because* it will be queried):
34
34
 
35
35
  ```ruby
36
- class AddLeadTimeDaysToSpreeProducts < ActiveRecord::Migration[7.2]
36
+ class AddLeadTimeDaysToSpreeProducts < ActiveRecord::Migration[8.1]
37
37
  def change
38
38
  add_column :spree_products, :lead_time_days, :integer
39
39
  add_index :spree_products, :lead_time_days
@@ -58,22 +58,8 @@ Spree.api.admin_product_serializer = 'Admin::ProductSerializer'
58
58
  **The permitted param** — makes it *writable* (the form's Save ships it in the same PATCH as core fields). The read and write names must match:
59
59
 
60
60
  ```ruby
61
- # app/controllers/spree/api/v3/admin/products_controller_decorator.rb
62
- module Spree
63
- module Api
64
- module V3
65
- module Admin
66
- module ProductsControllerDecorator
67
- def permitted_params
68
- super.merge(params.permit(:lead_time_days))
69
- end
70
- end
71
-
72
- ProductsController.prepend(ProductsControllerDecorator)
73
- end
74
- end
75
- end
76
- end
61
+ # config/initializers/spree.rb
62
+ Spree::Product.additional_permitted_attributes += [:lead_time_days]
77
63
  ```
78
64
 
79
65
  **The Ransack allowlist** — makes it *filterable and sortable*. Both the list's sort param and every filter predicate go through Ransack, and Ransack refuses attributes that aren't allowlisted:
@@ -208,4 +194,3 @@ Two `ColumnDef` extras worth knowing: `ransackAttribute` points the predicate so
208
194
  - [Custom form field recipe](custom-form-field.md) — the form layer in isolation, and the custom-field-definition path
209
195
  - [Tables](../customization/tables.md) — the full `ColumnDef` and table registry API
210
196
  - [Backend integration](../customization/backend.md) — serializers, permitted params, error mapping
211
- - [Decorators](../../customization/decorators.md) — the `prepend` pattern used for the controller
@@ -80,7 +80,7 @@ volumes:
80
80
  docker compose up -d
81
81
  ```
82
82
 
83
- The database is migrated automatically on boot. Once DNS resolves, your store is live at `https://store.example.com` — admin at `/admin`, background jobs running inside the web container ([combined mode](quickstart.md#web-and-worker)).
83
+ The database is migrated automatically on boot. Once DNS resolves, your store is live at `https://store.example.com` — admin dashboard at `/dashboard`, background jobs running inside the web container ([combined mode](quickstart.md#web-and-worker)).
84
84
 
85
85
  ## Updating
86
86
 
@@ -419,13 +419,13 @@ aws application-autoscaling put-scaling-policy \
419
419
 
420
420
  ### Admin
421
421
 
422
- Access your admin panel at:
422
+ Access the admin dashboard at:
423
423
 
424
424
  ```
425
- https://<your-domain>/admin
425
+ https://<your-domain>/dashboard
426
426
  ```
427
427
 
428
- Default credentials are created during `db:seed`. Change them immediately after first login.
428
+ No default admin account is created. Seeding prints a one-time setup link where you create the first admin account — print it again with `bin/rails spree:setup:token`. For scripted installs, set `ADMIN_EMAIL` and `ADMIN_PASSWORD` before seeding to create the account directly.
429
429
 
430
430
  ### Database Migrations
431
431
 
@@ -82,22 +82,28 @@ Configure the queues with weights — Sidekiq's equivalent of Solid Queue's poll
82
82
  - [spree_events, 3]
83
83
  - [spree_exports, 3]
84
84
  - [spree_images, 3]
85
+ - [spree_stock_reservations, 5]
85
86
  - [spree_products, 3]
86
- - [spree_reports, 3]
87
87
  - [spree_variants, 3]
88
- - [spree_taxons, 3]
89
- - [spree_stock_location_stock_items, 3]
88
+ - [spree_categories, 3]
89
+ - [spree_collections, 3]
90
+ - [spree_stock_location_stock_levels, 3]
90
91
  - [spree_coupon_codes, 3]
91
92
  - [spree_addresses, 3]
92
93
  - [spree_gift_cards, 3]
93
94
  - [spree_webhooks, 3]
94
95
  - [spree_api_keys, 3]
95
96
  - [spree_search, 3]
97
+ - [spree_tax_identifiers, 3]
98
+ - [spree_payouts, 3]
99
+ - [spree_data_requests, 1]
96
100
  - [active_storage_transform, 3]
97
101
  - [active_storage_analysis, 1]
98
102
  - [active_storage_purge, 1]
99
103
  ```
100
104
 
105
+ Unlike Solid Queue, Sidekiq has no catch-all queue: a job on a queue missing from this list never runs. Every queue name you assign with `Spree.queues` in `config/initializers/spree.rb` must appear here. Any `Spree.queues` entry you don't assign uses `default`.
106
+
101
107
  Move the `config/recurring.yml` schedules to sidekiq-cron (it auto-loads `config/schedule.yml`):
102
108
 
103
109
  ```yaml config/schedule.yml
@@ -119,7 +119,6 @@ docker pull ghcr.io/spree/spree:latest
119
119
  | Tag | Description |
120
120
  |-----|-------------|
121
121
  | `latest` | Latest stable release |
122
- | `5.4.0` | Specific version |
123
- | `5.4` | Latest patch for a minor version |
122
+ | `6.0.0` | Specific version |
124
123
 
125
124
  Browse all tags on the [GitHub Packages page](https://github.com/spree/spree/pkgs/container/spree). The image drops into the compose file above in place of your custom tag.
@@ -28,7 +28,7 @@ Spree Backend → Webhook POST → Storefront → render email → send via Rese
28
28
 
29
29
  #### Setup
30
30
 
31
- 1. **Create a webhook endpoint** in Spree Admin → Settings → Developers → Webhooks:
31
+ 1. **Create a webhook endpoint** in Spree Admin → Settings → Developer → Webhooks:
32
32
  - **URL:** `https://your-storefront.com/api/webhooks/spree`
33
33
  - **Events:** `order.completed`, `order.canceled`, `order.shipped`, `customer.password_reset_requested`, `newsletter_subscriber.subscription_requested`
34
34
 
@@ -55,6 +55,8 @@ Spree Backend → Webhook POST → Storefront → render email → send via Rese
55
55
  | `customer.password_reset_requested` | Password reset link |
56
56
  | `newsletter_subscriber.subscription_requested` | Newsletter double opt-in confirmation link |
57
57
 
58
+ > **NOTE:** The storefront listens for `order.completed` and `order.shipped`, the names these events had before Spree 6.0. Spree 6.0 still sends them alongside the new names, `order.placed` and `order.fulfilled`, but stops in Spree 6.1. Before upgrading to 6.1, switch the storefront handlers and your webhook endpoint subscriptions to the new names.
59
+
58
60
  #### Custom Frameworks
59
61
 
60
62
  If you're not using the Next.js storefront, you can build your own webhook handler with any framework. Use `@spree/sdk/webhooks` for signature verification:
@@ -103,11 +103,11 @@ Optional. When configured, Spree uses [Meilisearch](../../integrations/search/me
103
103
  | `MEILISEARCH_URL` | `http://localhost:7700` | Meilisearch server URL |
104
104
  | `MEILISEARCH_API_KEY` | — | Meilisearch master key. Not needed for local development — only required when Meilisearch is started with `MEILI_MASTER_KEY`. |
105
105
 
106
- After setting these, enable the provider and reindex:
106
+ After setting these, add the `spree_meilisearch` gem, enable the provider and reindex:
107
107
 
108
108
  ```ruby
109
109
  # config/initializers/spree.rb
110
- Spree.search_provider = 'Spree::SearchProvider::Meilisearch'
110
+ Spree.search_provider = 'SpreeMeilisearch::SearchProvider'
111
111
  ```
112
112
 
113
113
 
@@ -27,11 +27,11 @@ The database is seeded on first boot. Your store is ready in a few minutes — i
27
27
  ### Admin
28
28
 
29
29
  ```
30
- https://<your-app-name>.onrender.com/admin # admin panel
30
+ https://<your-app-name>.onrender.com/dashboard # admin dashboard
31
31
  https://<your-app-name>.onrender.com/jobs # background jobs dashboard
32
32
  ```
33
33
 
34
- Default credentials are created during seeding. Change them immediately after first login.
34
+ No default admin account is created. Seeding prints a one-time setup link where you create the first admin account — print it again from the web service's **Shell** tab with `bin/rails spree:setup:token`.
35
35
 
36
36
  The `/jobs` dashboard uses HTTP Basic auth: user `jobs`, with a password the Blueprint generates — read it in the Render dashboard under the web service's **Environment** tab (`MISSION_CONTROL_PASSWORD`).
37
37
 
@@ -30,7 +30,7 @@ To try the flow quickly, you can seed a sample seller with an owner account, a p
30
30
 
31
31
 
32
32
  ```bash Spree CLI
33
- spree run rake spree:sellers:sample_data
33
+ spree rake spree:sellers:sample_data
34
34
  ```
35
35
 
36
36
  ```bash Without CLI
@@ -711,7 +711,7 @@ const tokens = await sellerClient.auth.login({
711
711
  email: 'owner@brightsparks.example',
712
712
  password: '…',
713
713
  })
714
- sellerClient.setToken(tokens.access_token)
714
+ sellerClient.setToken(tokens.token)
715
715
 
716
716
  // A user who runs several sellers picks which one they are acting as
717
717
  sellerClient.setSeller('sel_xxx')
@@ -58,7 +58,7 @@ Client → POST /api/v3/store/auth/login
58
58
  Client → subsequent calls with `Authorization: Bearer <Spree JWT>`
59
59
  ```
60
60
 
61
- The third-party JWT proves identity **once, at login**. After that, the client uses the Spree JWT for everything, and `/auth/refresh` rotates it via Spree's own refresh-token mechanism. Your existing CanCanCan rules, `current_user`, and serializer params just work.
61
+ The third-party JWT proves identity **once, at login**. After that, the client uses the Spree JWT for everything, and `/auth/refresh` rotates it via Spree's own refresh-token mechanism. Your existing authorization, `current_user`, and serializer params just work.
62
62
 
63
63
  ## Step 1: Create the Strategy Class
64
64
 
@@ -64,6 +64,10 @@ module SpreeAcmeCarrier
64
64
  class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
65
65
  def self.integration_class = 'SpreeAcmeCarrier::Integration'
66
66
 
67
+ # A carrier quotes real parcels to an address, so it can only price
68
+ # delivery methods that ship (not pickup or digital).
69
+ def self.requires_address? = true
70
+
67
71
  def estimates(package)
68
72
  quotes_for(package).map do |quote|
69
73
  Spree::DeliveryRateProvider::Estimate.new(
@@ -148,10 +152,14 @@ end
148
152
 
149
153
  ```ruby
150
154
  # config/initializers/spree.rb
151
- Spree.delivery_rate_providers << 'SpreeAcmeCarrier::DeliveryRateProvider'.constantize
152
- Spree.integrations << 'SpreeAcmeCarrier::Integration'
155
+ Rails.application.config.after_initialize do
156
+ Spree.delivery_rate_providers << SpreeAcmeCarrier::DeliveryRateProvider
157
+ Spree.integrations << 'SpreeAcmeCarrier::Integration'
158
+ end
153
159
  ```
154
160
 
161
+ Register inside `after_initialize`: Spree sets up its own integrations list after your initializers run, so an entry added at the top level of the file would be dropped.
162
+
155
163
  Registration is what makes a provider selectable: Spree validates `rate_provider` against this list, so a typo fails when the method is saved rather than deep inside checkout.
156
164
 
157
165
  ## 4. Use it
@@ -190,7 +198,7 @@ class SpreeAcmeCarrier::Integration < Spree::Integration
190
198
 
191
199
  {
192
200
  tracking_code: payload['tracking_code'],
193
- tracking_status: STATUS_MAP.fetch(payload['status'], 'unknown'), # Spree::Fulfillment::TRACKING_STATUSES
201
+ tracking_status: STATUS_MAP.fetch(payload['status'], 'unknown'), # one of Spree::Delivery::STATUSES
194
202
  estimated_delivery_at: payload['eta'],
195
203
  delivered_at: payload['delivered_scan_at'],
196
204
  details: payload.slice('status_detail', 'carrier')
@@ -7,7 +7,7 @@ description: Change the shape of order, return and other document numbers — fr
7
7
 
8
8
  Every order, return, exchange, claim, stock transfer, import and export carries a **document number** — the short, human-readable reference a merchant reads out on a support call and a customer quotes in an email. Orders look like `R1001`, returns like `RET1001`.
9
9
 
10
- Numbers are separate from IDs. Every record also has a prefixed ID (`order_86Rf07xd4z`) which is what the API uses and what you should reference in code. The number exists purely so people can read, say and type it.
10
+ Numbers are separate from IDs. Every record also has a prefixed ID (`or_86Rf07xd4z`) which is what the API uses and what you should reference in code. The number exists purely so people can read, say and type it.
11
11
 
12
12
  There are two ways to change it. Merchants can reshape **order numbers** from the dashboard without any code. Developers can replace the generator for **any** document type when the settings are not enough.
13
13
 
@@ -8,14 +8,14 @@ import { Since } from '/snippets/since.mdx';
8
8
 
9
9
  ## Overview
10
10
 
11
- Order routing decides which [Stock Location](../core-concepts/inventory.md#stock-locations) fulfills an order at checkout. Spree gives you two extension points:
11
+ Order routing decides which [Stock Location](../core-concepts/inventory.md#stock-locations) fulfills an order when Spree builds the order's fulfillments (`Order#rebuild_fulfillments!`) — orders created or edited by staff through the Admin API or dashboard, and items added to an order after it was placed. Storefront checkout does not go through it: a cart builds its fulfillments with `Spree::Stock::Coordinator`, and completing checkout copies them onto the order. Spree gives you two extension points:
12
12
 
13
13
  - **Rules** — add a new signal to the existing rules-walking algorithm (proximity, customer tier, refrigerated SKUs, day-of-week dispatch).
14
14
  - **Strategies** — replace the algorithm entirely (delegate to a warehouse management system, run an ML model, call an optimization solver).
15
15
 
16
16
  This guide covers both. Most extensions are rules — they compose with the built-ins and don't require rewriting the pipeline.
17
17
 
18
- Before starting, make sure you understand [how order routing works in Spree](../core-concepts/fulfillments.md#order-routing).
18
+ Before starting, make sure you understand how [fulfillments](../core-concepts/fulfillments.md) and [stock locations](../core-concepts/inventory.md) work in Spree.
19
19
 
20
20
  | If the answer is "yes" | Pick |
21
21
  |---|---|
@@ -159,8 +159,8 @@ The contract is `Spree::OrderRouting::Strategy::Base`. There are no defaults —
159
159
 
160
160
  | Method | When it fires | Returns |
161
161
  |---|---|---|
162
- | `#for_allocation` | Cart → checkout transition (`Order#create_proposed_shipments`) | `Array<Spree::Stock::Package>` |
163
- | `#for_sale(fulfillment:)` | A shipment ships | (side effect) |
162
+ | `#for_allocation` | The order's fulfillments are built (`Order#rebuild_fulfillments!`) | `Array<Spree::Stock::Package>` |
163
+ | `#for_sale(fulfillment:)` | A fulfillment ships | (side effect) |
164
164
  | `#for_release` | An in-flight order is canceled before shipping | (side effect) |
165
165
  | `#for_cancellation` | A shipped order is canceled (return) | (side effect) |
166
166
 
@@ -205,10 +205,11 @@ module Acme
205
205
 
206
206
  def build_package(assignment)
207
207
  location = Spree::StockLocation.find_by!(code: assignment.location_code)
208
- units = order.inventory_units.where(variant_id: assignment.variant_ids).to_a
208
+ units = Spree::Stock::InventoryUnitBuilder.new(order).units
209
+ .select { |unit| assignment.variant_ids.include?(unit.variant_id) }
209
210
 
210
- package = Spree::Stock::Packer.new(location, units, Spree.stock_splitters).packages.first
211
- package.shipping_rates = Spree::Stock::Estimator.new(order).shipping_rates(package)
211
+ package = Spree::Stock::Packer.new(location, units, Spree.stock_splitters, owner: order).packages.first
212
+ package.delivery_rates = Spree::Stock::Estimator.new(order).delivery_rates(package)
212
213
  package
213
214
  end
214
215
 
@@ -223,8 +224,8 @@ end
223
224
  Notes:
224
225
 
225
226
  - **Strategies are plain Ruby classes**, not ActiveRecord models. Live under `app/models/` so the autoloader picks them up; or anywhere on the load path if you'd rather organize them as services.
226
- - **`for_allocation` returns `Spree::Stock::Package` objects.** The order's `create_proposed_shipments` turns those into `Shipment`s by calling `package.to_shipment`. Returning shipments directly will break the call site.
227
- - **Reuse the existing primitives.** `Spree::Stock::Packer`, `Spree::Stock::Estimator`, and `Spree::Stock::InventoryUnitBuilder` handle packing, rate estimation, and inventory unit construction. Custom strategies are about the location decision, not re-implementing the packing pipeline.
227
+ - **`for_allocation` returns `Spree::Stock::Package` objects.** The order's `rebuild_fulfillments!` turns those into `Spree::Fulfillment`s by calling `package.to_fulfillment`. Returning fulfillments directly will break the call site.
228
+ - **Reuse the existing primitives.** `Spree::Stock::Packer`, `Spree::Stock::Estimator`, and `Spree::Stock::InventoryUnitBuilder` handle packing, delivery rate estimation, and building the units to pack. Custom strategies are about the location decision, not re-implementing the packing pipeline.
228
229
 
229
230
  ### Step 2: Register the Strategy
230
231
 
@@ -258,7 +259,7 @@ Resolution order: `channel.preferred_order_routing_strategy` → `store.preferre
258
259
 
259
260
  ### Step 3: Test the Strategy
260
261
 
261
- Strategy tests are integration tests — build an order, instantiate the strategy, exercise the four methods, assert on the resulting shipments and mocked side effects.
262
+ Strategy tests are integration tests — build an order, instantiate the strategy, exercise the four methods, assert on the resulting packages and mocked side effects.
262
263
 
263
264
  ```ruby spec/models/acme/oms/strategy_spec.rb
264
265
  require 'rails_helper'
@@ -293,15 +294,15 @@ end
293
294
 
294
295
  - **Forgetting the lifecycle hooks.** `for_release` and `for_cancellation` raise `NotImplementedError` by default. If your algorithm doesn't need post-allocation hooks, override them as no-ops explicitly.
295
296
  - **Ignoring inventory.** Even custom strategies should query `Spree::StockLocation.active` and respect the order's reserved units. Hardcoding `Spree::StockLocation.first` will work in tests and break in production.
296
- - **Side effects in `for_sale` / `for_release`.** These fire from state-machine callbacks where the order may already be partially mutated. Treat the methods as side-effect endpoints; pull what you need from the `order` and `fulfillment` arguments — don't reload state mid-call.
297
+ - **Side effects in `for_sale` / `for_release`.** These fire while the order or fulfillment is changing status, so the order may already be partially mutated. Treat the methods as side-effect endpoints; pull what you need from the `order` and `fulfillment` arguments — don't reload state mid-call.
297
298
 
298
299
  ## Coexistence with Stock Reservations
299
300
 
300
- Stock reservations and order routing are independent systems in 5.5 — they make decisions at different times and protect different invariants.
301
+ Stock reservations and order routing are independent systems — they make decisions at different times and protect different invariants.
301
302
 
302
303
  | Concern | Reservation system | Order routing |
303
304
  |---|---|---|
304
- | **When it fires** | Cart mutation (add item, change qty, enter checkout) | Cart → checkout transition |
305
+ | **When it fires** | Cart changes during checkout (add item, change quantity); released when the order is placed | When an order's fulfillments are built (`Order#rebuild_fulfillments!`) |
305
306
  | **What it decides** | How many units of a variant are held for this cart | Which `StockLocation` fulfills the order |
306
307
  | **Granularity** | Per-variant | Per-order |
307
308
 
@@ -310,7 +311,7 @@ Stock reservations and order routing are independent systems in 5.5 — they mak
310
311
  What this means for you when writing custom rules and strategies:
311
312
 
312
313
  - **Don't read `StockReservation` from inside a routing rule's `#rank`.** The reservations were created against arbitrary stock_items at cart time and don't reflect the routing decision.
313
- - **Don't relocate reservations from a custom strategy's `for_allocation`.** That's the path 6.0 codifies; doing it ad-hoc in 5.5 races against the cart services that own reservation lifecycle.
314
+ - **Don't relocate reservations from a custom strategy's `for_allocation`.** Reservations belong to the cart workflows that create and release them; moving them from a strategy races against those workflows.
314
315
  - **`AvailabilityValidator` is the safety net.** If a routing decision picks a location that's actually short on stock, the validator catches it before the order completes.
315
316
 
316
317
  ## Next Steps
@@ -98,11 +98,12 @@ class MyGateway < Spree::PaymentMethod
98
98
  provider_session = MyProvider::Client.new(preferred_api_key).create_session(
99
99
  amount: (total * 100).to_i, # amount in cents
100
100
  currency: order.currency,
101
- metadata: { order_number: order.number }
101
+ metadata: { cart_id: order.prefixed_id }
102
102
  )
103
103
 
104
+ # `order:` receives the cart during checkout — assign it as the owner
104
105
  payment_sessions.create!(
105
- order: order,
106
+ owner: order,
106
107
  amount: total,
107
108
  currency: order.currency,
108
109
  external_id: provider_session.id,
@@ -130,8 +131,7 @@ class MyGateway < Spree::PaymentMethod
130
131
  #
131
132
  # Responsibilities:
132
133
  # - Verify payment status with the provider
133
- # - Create the Spree::Payment record
134
- # - Transition the payment to the correct state
134
+ # - Create the Spree::Payment record and move it to the right status
135
135
  # - Mark the session as completed/failed
136
136
  #
137
137
  # Must NOT complete the order — that is handled by Carts::Complete
@@ -143,21 +143,19 @@ class MyGateway < Spree::PaymentMethod
143
143
  )
144
144
 
145
145
  if result.status == 'succeeded'
146
- payment_session.process!
146
+ payment_session.process if payment_session.can_process?
147
147
 
148
- # Create the Spree::Payment record
149
- payment = payment_session.find_or_create_payment!
148
+ # Create the Spree::Payment record (or find the one a webhook already
149
+ # created) and mark it completed — captured: false leaves it pending
150
+ # for an authorization-only payment
151
+ payment_session.settle_payment!(captured: true)
150
152
 
151
- # Transition the payment to completed
152
- if payment.present? && !payment.completed?
153
- payment.started_processing! if payment.checkout?
154
- payment.complete! if payment.can_complete?
155
- end
156
-
157
- payment_session.complete!
153
+ payment_session.complete unless payment_session.completed?
158
154
  else
159
- payment_session.fail!
155
+ payment_session.fail if payment_session.can_fail?
160
156
  end
157
+
158
+ payment_session
161
159
  end
162
160
 
163
161
  def payment_icon_name
@@ -171,8 +169,8 @@ end
171
169
  The frontend creates a session, then uses the provider's SDK to collect payment:
172
170
 
173
171
  ```typescript
174
- // 1. Get available payment methods
175
- const methods = await client.carts.paymentMethods.list(cart.id, options)
172
+ // 1. Get available payment methods (embedded in the cart)
173
+ const methods = cart.payment_methods
176
174
 
177
175
  // 2. Find your gateway (check session_required flag)
178
176
  const myGateway = methods.find(m => m.session_required)
@@ -327,9 +325,9 @@ class MyGateway < Spree::PaymentMethod
327
325
  setup_session.update!(
328
326
  payment_source: credit_card
329
327
  )
330
- setup_session.complete!
328
+ setup_session.complete
331
329
  else
332
- setup_session.fail!
330
+ setup_session.fail
333
331
  end
334
332
 
335
333
  setup_session
@@ -61,7 +61,7 @@ end
61
61
 
62
62
  The `options` hash passed to `eligible?` can include `:user`, `:email`, and other context from the checkout flow.
63
63
 
64
- > **WARNING:** Accept **both** `Spree::Cart` and `Spree::Order` in `applicable?`. Promotions are evaluated on every cart change, long before the cart becomes an order, and a cart is its own model rather than an unfinished order. A rule guarding on `Spree::Order` alone silently never applies during checkout — the promotion just appears not to work. Every built-in rule accepts both.
64
+ > **WARNING:** Accept **both** `Spree::Cart` and `Spree::Order` in `applicable?`. Promotions are evaluated on every cart change, long before the cart becomes an order, and a cart is its own model rather than an unfinished order. A rule that isn't applicable is skipped rather than failed, so a rule guarding on `Spree::Order` alone is silently ignored during checkout — its condition is never checked and the promotion applies as if the rule weren't there. Every built-in rule accepts both.
65
65
 
66
66
  #### Using Preferences
67
67
 
@@ -258,7 +258,7 @@ Like rules, registered actions surface automatically in the dashboard's promotio
258
258
 
259
259
  ## Custom Adjusters
260
260
 
261
- Not every charge or reduction is a promotion. A gift wrap fee, a payment surcharge, or loyalty pricing has no rules, no coupon codes, and no competition — it just needs to be on the order whenever it applies. That's an **adjuster**: a class invoked on every recalculation that owns a family of [Fee](../core-concepts/fees.md) or [Discount](../core-concepts/discounts.md) rows.
261
+ Not every charge or reduction is a promotion. A gift wrap fee or a payment surcharge has no rules, no coupon codes, and no competition — it just needs to be on the order whenever it applies. That's an **adjuster**: a class invoked on every recalculation that owns a family of [Fee](../core-concepts/fees.md) rows.
262
262
 
263
263
  ```ruby server/app/models/my_app/adjusters/gift_wrap.rb
264
264
  module MyApp
@@ -284,11 +284,11 @@ Rails.application.config.after_initialize do
284
284
  end
285
285
  ```
286
286
 
287
- The contract is one method: `update`. It runs on every recalculation, so it must be idempotent — write the rows that should exist, remove the ones that shouldn't (that's why the example uses `find_or_initialize_by` keyed by `kind` rather than `create`). The `order` it receives is the cart during checkout and the order after placement, and rows attach to it either way.
287
+ The contract is one method: `update`. It runs on every recalculation, so it must be idempotent — write the rows that should exist, remove the ones that shouldn't (that's why the example uses `find_or_initialize_by` keyed by `kind` rather than `create`). The `order` it receives is the cart during checkout. Adjusters don't run once an order is placed — a placed order's discount, fee and tax rows are frozen and only re-summed, so post-placement changes go through the admin discount and fee endpoints.
288
288
 
289
289
  There's no totals bookkeeping to do: after all adjusters run, Spree re-sums the typed rows into the order totals and the tax provider estimates tax on the result — a fee you write here gets taxed in the same pass, like any other fee.
290
290
 
291
- Custom discounts work the same way, writing `order.discounts` rows with your own `kind` (e.g. `'loyalty'`). Use a kind other than `'promotion'` — promotion rows belong to the promotion engine, which removes any it didn't write itself.
291
+ Discount rows are limited to two kinds, `promotion` and `manual`, and promotion rows belong to the promotion engine, which removes any it didn't write itself. Reductions that need rules, codes or stacking are best modeled as a [custom promotion action](#custom-promotion-actions); note that the order's `discount_total` counts promotion discounts only.
292
292
 
293
293
  ## Testing
294
294
 
@@ -14,7 +14,7 @@ This guide walks you through building a custom search provider for Spree. By the
14
14
 
15
15
  Before starting, make sure you understand [how search and filtering works in Spree](../core-concepts/search-filtering.md).
16
16
 
17
- > **INFO:** Spree ships with a built-in [Meilisearch provider](../../integrations/search/meilisearch.md). If Meilisearch fits your needs, you don't need to build a custom provider — just configure it.
17
+ > **INFO:** Spree offers an official [Meilisearch provider](../../integrations/search/meilisearch.md) in the `spree_meilisearch` gem. If Meilisearch fits your needs, you don't need to build a custom provider — just install and configure it.
18
18
 
19
19
  ## Architecture
20
20
 
@@ -22,7 +22,7 @@ Before starting, make sure you understand [how search and filtering works in Spr
22
22
  Store API Request (locale=de, currency=EUR)
23
23
  │
24
24
  ├─ AR Scope (security + visibility)
25
- │ store.products.active(currency).accessible_by(ability)
25
+ │ store.products.available(now, currency), narrowed to the channel/catalogs
26
26
  │
27
27
  └─ Search Provider (search + filter + facets)
28
28
  ├─ Database (default): ILIKE + Ransack + FiltersAggregator
@@ -109,7 +109,7 @@ Products are indexed as **one document per market × locale** combination. The `
109
109
 
110
110
  ```ruby app/models/my_app/search_provider/typesense.rb
111
111
  def index(product)
112
- documents = Spree::SearchProvider::ProductPresenter.new(product, store).call
112
+ documents = presenter_class.new(product, store).call
113
113
  documents.each do |doc|
114
114
  client.collections[index_name].documents.upsert(doc)
115
115
  end
@@ -125,8 +125,15 @@ def remove_by_id(prefixed_id)
125
125
  filter_by: "product_id:=#{prefixed_id}"
126
126
  )
127
127
  end
128
+
129
+ # The registered presenter — SpreeMeilisearch::ProductPresenter when the gem is installed
130
+ def presenter_class
131
+ Spree::Dependencies.search_product_presenter_class
132
+ end
128
133
  ```
129
134
 
135
+ > **NOTE:** `SpreeMeilisearch::ProductPresenter` ships with the `spree_meilisearch` gem — add the gem to reuse its document shape. Otherwise, write your own presenter and register it with `Spree::Dependencies.search_product_presenter`.
136
+
130
137
  The `ProductPresenter` returns an array of documents. For a store with US (USD/English) and EU (EUR/German+French) markets, one product produces 3 documents — each with flat `name`, `price`, `locale`, `currency` fields.
131
138
 
132
139
  ## Step 4: Implement Bulk Reindex
@@ -198,7 +205,7 @@ Spree::SearchProvider::SearchResult.new(
198
205
  ### ProductPresenter
199
206
 
200
207
  ```ruby
201
- documents = Spree::SearchProvider::ProductPresenter.new(product, store).call
208
+ documents = SpreeMeilisearch::ProductPresenter.new(product, store).call
202
209
  # => [
203
210
  # { prefixed_id: "prod_abc_en_USD", product_id: "prod_abc", locale: "en",
204
211
  # currency: "USD", name: "Blue Shirt", price: 29.99, ... },
@@ -233,4 +240,4 @@ Document shaping for `cf_*` fields stays on `search_product_presenter` — the o
233
240
  ## Related Documentation
234
241
 
235
242
  - [Search & Filtering](../core-concepts/search-filtering.md) — Store API search reference
236
- - [Meilisearch Integration](../../integrations/search/meilisearch.md) — Built-in Meilisearch provider setup
243
+ - [Meilisearch Integration](../../integrations/search/meilisearch.md) — Meilisearch provider setup
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  title: Build a Custom Stock Splitter
3
- description: Step-by-step guide to extending Spree's stock splitter chain — break a location's allocation into multiple shipments along your own axis (refrigeration, gift wrap, bin size, hazmat, anything you need to physically separate).
3
+ description: Step-by-step guide to extending Spree's stock splitter chain — break a location's allocation into multiple fulfillments along your own axis (refrigeration, gift wrap, bin size, hazmat, anything you need to physically separate).
4
4
  ---
5
5
 
6
6
  ## Overview
7
7
 
8
- When [Order Routing](../core-concepts/fulfillments.md#order-routing) picks one or more stock locations to fulfill an order, each location's allocation is then run through a chain of **splitters**. Each splitter looks at the packages produced so far and decides whether to break them further along its own axis.
8
+ When [Order Routing](custom-order-routing.md) picks one or more stock locations to fulfill an order, each location's allocation is then run through a chain of **splitters**. Each splitter looks at the packages produced so far and decides whether to break them further along its own axis.
9
9
 
10
- Spree ships with four splitters out of the box (`ShippingCategory`, `Backordered`, `Digital`, `Weight`). You add your own when you need a *physical* separation that isn't expressed by any of the existing ones — refrigerated SKUs that can't share a box with ambient ones, hazmat goods that need their own carrier label, gift-wrap items that ship from a separate processing room, and so on.
10
+ Spree ships with three splitters out of the box: `DeliveryProfile` (keeps each package to a single [delivery profile](../core-concepts/delivery-setup.md#delivery-profiles)), `Backordered` and `Weight`. The default chain is `DeliveryProfile` then `Backordered`; `Weight` is opt-in. You add your own when you need a *physical* separation that isn't expressed by any of the existing ones — refrigerated SKUs that can't share a box with ambient ones, hazmat goods that need their own carrier label, gift-wrap items that ship from a separate processing room, and so on.
11
11
 
12
- Before starting, make sure you understand [how splitting works in Spree](../core-concepts/fulfillments.md#stock-splitters) and the [order routing](../core-concepts/fulfillments.md#order-routing) layer that runs before splitters.
12
+ Before starting, make sure you understand [how delivery profiles split a cart](../core-concepts/delivery-setup.md#delivery-profiles) and the [order routing](custom-order-routing.md) layer that runs before splitters.
13
13
 
14
14
  | If the answer is "yes" | Pick |
15
15
  |---|---|
@@ -74,12 +74,12 @@ end
74
74
 
75
75
  #### What `package.contents` Looks Like
76
76
 
77
- Each `package.contents` is an array of `Spree::Stock::ContentItem` — wrappers around `InventoryUnit`. The two attributes you'll use most:
77
+ Each `package.contents` is an array of `Spree::Stock::ContentItem` — wrappers around a `Spree::FulfillmentItem`. The attributes you'll use most:
78
78
 
79
79
  ```ruby
80
80
  content.variant # The Spree::Variant being shipped
81
- content.inventory_unit # The Spree::InventoryUnit (gives access to line_item, order, etc.)
82
- content.weight # Convenience: variant.weight × inventory_unit.quantity
81
+ content.inventory_unit # The Spree::FulfillmentItem (gives access to line_item, quantity, etc.)
82
+ content.weight # Convenience: variant.weight × quantity
83
83
  content.state # :on_hand or :backordered
84
84
  ```
85
85
 
@@ -90,43 +90,44 @@ So the splitter's job is "look at each package's contents, decide which contents
90
90
  Splitters are registered globally on `Rails.application.config.spree.stock_splitters` — every order runs through the full chain. Add yours in `config/initializers/spree.rb`:
91
91
 
92
92
  ```ruby config/initializers/spree.rb
93
- Rails.application.config.to_prepare do
93
+ Rails.application.config.after_initialize do
94
94
  Rails.application.config.spree.stock_splitters << Spree::Stock::Splitter::Refrigerated
95
95
  end
96
96
  ```
97
97
 
98
- The `to_prepare` block re-runs on Zeitwerk code reloads in development, so the splitter survives reloads correctly. Putting the line at the top of the initializer (outside `to_prepare`) works too in production but can leave a stale registry in development.
98
+ Spree assigns its default chain in its own `after_initialize`, which runs after your initializer files — so a splitter appended at the top level of the file, or in a `to_prepare` block, is overwritten at boot. Registering in `after_initialize` runs after Spree's defaults. The chain holds the class loaded at boot, so restart the server in development after changing the splitter.
99
99
 
100
100
  #### Replacing the Whole Chain
101
101
 
102
102
  If you'd rather control the full chain (uncommon — the defaults are well-chosen), assign instead of append:
103
103
 
104
104
  ```ruby
105
- Rails.application.config.spree.stock_splitters = [
106
- Spree::Stock::Splitter::ShippingCategory,
107
- Spree::Stock::Splitter::Refrigerated, # custom one slotted in
108
- Spree::Stock::Splitter::Backordered,
109
- Spree::Stock::Splitter::Digital
110
- ]
105
+ Rails.application.config.after_initialize do
106
+ Rails.application.config.spree.stock_splitters = [
107
+ Spree::Stock::Splitter::DeliveryProfile,
108
+ Spree::Stock::Splitter::Refrigerated, # custom one slotted in
109
+ Spree::Stock::Splitter::Backordered
110
+ ]
111
+ end
111
112
  ```
112
113
 
113
114
  #### Ordering Matters
114
115
 
115
116
  Splitters run in array order, each feeding the next. Two practical rules:
116
117
 
117
- 1. **Coarse before fine.** `ShippingCategory` runs first by default because it groups packages by carrier-relevant category before any other axis cuts in. Your custom splitter usually wants to run after it, unless you're separating items that should *never* share a package even within a category (e.g. hazmat).
118
+ 1. **Coarse before fine.** `DeliveryProfile` runs first by default because it groups packages by delivery profile — which decides the delivery methods a package can use — before any other axis cuts in. Your custom splitter usually wants to run after it, unless you're separating items that should *never* share a package even within a profile (e.g. hazmat).
118
119
  2. **`Backordered` should usually be last among the "type" splitters.** It splits on-hand from backordered items, which is a state-axis split rather than a packaging-axis split. Splitting before `Backordered` gives you finer category buckets; splitting after does too — pick based on whether your axis applies to backorders. Refrigerated items have the same handling whether on-hand or backordered, so running before `Backordered` is fine. Hazmat shipping rules might differ between on-hand (real package) and backorder (paperwork only), so running after might be cleaner.
119
120
 
120
121
  ### Step 3: Test the Splitter
121
122
 
122
- Splitter tests are unit tests — instantiate a fake `Packer`, hand the splitter a hand-built package, assert on the output. There's no factory required.
123
+ Splitter tests are unit tests — instantiate a `Packer`, hand the splitter a hand-built package, assert on the output. There's no factory required.
123
124
 
124
125
  ```ruby spec/models/spree/stock/splitter/refrigerated_spec.rb
125
126
  require 'rails_helper'
126
127
 
127
128
  RSpec.describe Spree::Stock::Splitter::Refrigerated, type: :model do
128
129
  let(:stock_location) { build_stubbed(:stock_location) }
129
- let(:packer) { instance_double(Spree::Stock::Packer, stock_location: stock_location) }
130
+ let(:packer) { Spree::Stock::Packer.new(stock_location, []) }
130
131
  let(:cold_variant) { build_stubbed(:variant).tap { |v| allow(v).to receive(:refrigerated?).and_return(true) } }
131
132
  let(:warm_variant) { build_stubbed(:variant).tap { |v| allow(v).to receive(:refrigerated?).and_return(false) } }
132
133
 
@@ -162,8 +163,8 @@ RSpec.describe Spree::Stock::Splitter::Refrigerated, type: :model do
162
163
  end
163
164
 
164
165
  def content_item_for(variant)
165
- inventory_unit = build_stubbed(:inventory_unit, variant: variant)
166
- Spree::Stock::ContentItem.new(inventory_unit, :on_hand)
166
+ fulfillment_item = build_stubbed(:fulfillment_item, variant: variant)
167
+ Spree::Stock::ContentItem.new(fulfillment_item, :on_hand)
167
168
  end
168
169
  end
169
170
  ```
@@ -197,8 +198,8 @@ Splitters and routing run in series, not in parallel — every package a splitte
197
198
  |---|---|---|
198
199
  | 1 | Routing | `Strategy::Rules#for_allocation` ranks all eligible locations |
199
200
  | 2 | Per-location packing | One `Packer` per location runs the full splitter chain |
200
- | 3 | Cross-location dedup | `Prioritizer.Adjuster` walks packages in rank order, assigns each inventory unit to the first package with on-hand stock |
201
- | 4 | Rate estimation | `Estimator` attaches shipping rates |
201
+ | 3 | Cross-location dedup | `Prioritizer.Adjuster` walks packages in rank order, assigns each unit to the first package with on-hand stock |
202
+ | 4 | Rate estimation | `Estimator` attaches delivery rates |
202
203
 
203
204
  Practical implications:
204
205
 
@@ -208,6 +209,6 @@ Practical implications:
208
209
 
209
210
  ## Next Steps
210
211
 
211
- - [Shipments — Stock Splitters](../core-concepts/fulfillments.md#stock-splitters) — Concept overview and built-in splitter list
212
- - [Build Custom Order Routing](custom-order-routing.md) — The other layer of split-shipment customization
212
+ - [Delivery Setup — Delivery profiles](../core-concepts/delivery-setup.md#delivery-profiles) — Why every package keeps to a single delivery profile
213
+ - [Build Custom Order Routing](custom-order-routing.md) — The other layer of split-fulfillment customization
213
214
  - [Custom Fields](../core-concepts/metafields.md) — Tag variants with the data your splitter reads