@spree/docs 0.1.247 → 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.
- package/dist/api-reference/admin-api/authentication.md +34 -14
- package/dist/api-reference/admin-api/endpoints.md +366 -14
- package/dist/api-reference/admin-api/errors.md +2 -2
- package/dist/api-reference/admin-api/introduction.md +3 -3
- package/dist/api-reference/admin-api/querying.md +6 -6
- package/dist/api-reference/store-api/monetary-amounts.md +5 -5
- package/dist/api-reference/webhooks-events.md +330 -335
- package/dist/developer/agentic/agent-skills.md +5 -2
- package/dist/developer/agentic/llm-docs.md +2 -1
- package/dist/developer/cli/admin-api.md +1 -1
- package/dist/developer/cli/quickstart.md +2 -2
- package/dist/developer/contributing/creating-an-extension.md +292 -146
- package/dist/developer/contributing/developing-spree.md +13 -17
- package/dist/developer/core-concepts/catalogs.md +2 -2
- package/dist/developer/core-concepts/channels.md +3 -3
- package/dist/developer/core-concepts/companies.md +2 -1
- package/dist/developer/core-concepts/delivery-setup.md +2 -2
- package/dist/developer/core-concepts/discounts.md +3 -3
- package/dist/developer/core-concepts/events.md +6 -5
- package/dist/developer/core-concepts/freight.md +3 -2
- package/dist/developer/core-concepts/fulfillments.md +10 -8
- package/dist/developer/core-concepts/imports-exports.md +11 -8
- package/dist/developer/core-concepts/inventory.md +2 -2
- package/dist/developer/core-concepts/media.md +14 -14
- package/dist/developer/core-concepts/orders.md +2 -2
- package/dist/developer/core-concepts/payments.md +1 -2
- package/dist/developer/core-concepts/products.md +6 -6
- package/dist/developer/core-concepts/reporting.md +4 -3
- package/dist/developer/core-concepts/returns-exchanges-claims.md +7 -7
- package/dist/developer/core-concepts/search-filtering.md +3 -3
- package/dist/developer/core-concepts/sellers.md +22 -3
- package/dist/developer/core-concepts/staff-roles.md +4 -2
- package/dist/developer/core-concepts/store-credits-gift-cards.md +1 -1
- package/dist/developer/core-concepts/stores.md +2 -2
- package/dist/developer/core-concepts/translations.md +12 -8
- package/dist/developer/core-concepts/webhooks.md +19 -18
- package/dist/developer/create-spree-app/quickstart.md +2 -7
- package/dist/developer/customization/api.md +1 -1
- package/dist/developer/customization/checkout.md +2 -2
- package/dist/developer/customization/dependencies.md +53 -37
- package/dist/developer/customization/permissions.md +2 -2
- package/dist/developer/dashboard/concepts.md +1 -1
- package/dist/developer/dashboard/customization/navigation.md +3 -2
- package/dist/developer/dashboard/customization/permissions.md +6 -6
- package/dist/developer/dashboard/plugins/publishing.md +4 -4
- package/dist/developer/dashboard/plugins/scaffolding.md +1 -1
- package/dist/developer/dashboard/public-api.md +1 -1
- package/dist/developer/dashboard/recipes/attribute-end-to-end.md +3 -18
- package/dist/developer/deployment/aws.md +1 -1
- package/dist/developer/deployment/aws_ecs.md +3 -3
- package/dist/developer/deployment/background_jobs.md +9 -3
- package/dist/developer/deployment/docker.md +1 -2
- package/dist/developer/deployment/emails.md +3 -1
- package/dist/developer/deployment/environment_variables.md +2 -2
- package/dist/developer/deployment/render.md +2 -2
- package/dist/developer/how-to/build-a-marketplace.md +2 -2
- package/dist/developer/how-to/custom-api-authentication.md +1 -1
- package/dist/developer/how-to/custom-delivery-rate-provider.md +11 -3
- package/dist/developer/how-to/custom-document-numbers.md +1 -1
- package/dist/developer/how-to/custom-order-routing.md +15 -14
- package/dist/developer/how-to/custom-payment-method.md +17 -19
- package/dist/developer/how-to/custom-promotion.md +4 -4
- package/dist/developer/how-to/custom-search-provider.md +12 -5
- package/dist/developer/how-to/custom-stock-splitter.md +25 -24
- package/dist/developer/how-to/sell-digital-products.md +1 -1
- package/dist/developer/multi-tenant/quickstart.md +2 -2
- package/dist/developer/providers/payouts.md +6 -2
- package/dist/developer/sdk/admin/querying-and-errors.md +1 -1
- package/dist/developer/sdk/admin/quickstart.md +4 -4
- package/dist/developer/sdk/authentication.md +5 -2
- package/dist/developer/sdk/store/cart-checkout.md +4 -4
- package/dist/developer/storefront/nextjs/emails.md +4 -2
- package/dist/developer/storefront/nextjs/testing.md +1 -1
- package/dist/developer/upgrades/5.6-to-6.0.md +51 -20
- package/dist/integrations/search/meilisearch.md +4 -4
- 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[
|
|
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
|
-
#
|
|
62
|
-
|
|
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 `/
|
|
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
|
|
422
|
+
Access the admin dashboard at:
|
|
423
423
|
|
|
424
424
|
```
|
|
425
|
-
https://<your-domain>/
|
|
425
|
+
https://<your-domain>/dashboard
|
|
426
426
|
```
|
|
427
427
|
|
|
428
|
-
|
|
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
|
-
- [
|
|
89
|
-
- [
|
|
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
|
-
| `
|
|
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 →
|
|
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 = '
|
|
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/
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
152
|
-
Spree.
|
|
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::
|
|
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 (`
|
|
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
|
|
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
|
|
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` |
|
|
163
|
-
| `#for_sale(fulfillment:)` | A
|
|
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 =
|
|
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.
|
|
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 `
|
|
227
|
-
- **Reuse the existing primitives.** `Spree::Stock::Packer`, `Spree::Stock::Estimator`, and `Spree::Stock::InventoryUnitBuilder` handle packing, rate estimation, and
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`.**
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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 =
|
|
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) —
|
|
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
|
|
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](
|
|
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
|
|
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
|
|
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 `
|
|
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::
|
|
82
|
-
content.weight # Convenience: variant.weight ×
|
|
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.
|
|
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
|
-
|
|
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.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.** `
|
|
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
|
|
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) {
|
|
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
|
-
|
|
166
|
-
Spree::Stock::ContentItem.new(
|
|
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
|
|
201
|
-
| 4 | Rate estimation | `Estimator` attaches
|
|
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
|
-
- [
|
|
212
|
-
- [Build Custom Order Routing](custom-order-routing.md) — The other layer of split-
|
|
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
|