@spree/docs 0.1.141 → 0.1.143
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/store.yaml +1562 -1492
- package/dist/developer/customization/api.md +1 -1
- package/dist/developer/customization/decorators.md +0 -1
- package/dist/developer/customization/dependencies.md +88 -36
- package/dist/developer/customization/workflows.md +383 -0
- package/dist/developer/getting-started/quickstart.md +12 -49
- package/dist/developer/upgrades/5.6-to-6.0.md +242 -0
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ title: Customizing the Spree API
|
|
|
3
3
|
description: Add new Store API endpoints, customize existing JSON responses with serializer decorators, and extend Spree's REST API to fit your storefront needs.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Before you start customizing Spree API endpoints, make sure you reviewed all existing API endpoints in the [Spree API docs](
|
|
6
|
+
Before you start customizing Spree API endpoints, make sure you reviewed all existing API endpoints in the [Spree API docs](../../api-reference/admin-api/endpoints.md).
|
|
7
7
|
|
|
8
8
|
For a step-by-step walkthrough of adding a complete new resource (model, serializer, controller, routes), see the [API tutorial](../tutorial/api.md).
|
|
9
9
|
|
|
@@ -532,4 +532,3 @@ end
|
|
|
532
532
|
- [Admin Partials](../admin/extending-ui.md) - Extend admin UI without view decorators
|
|
533
533
|
- [Extending Core Models Tutorial](../tutorial/extending-models.md) - Step-by-step guide to connecting custom models with Spree core
|
|
534
534
|
- [Customization Overview](quickstart.md) - General customization patterns
|
|
535
|
-
- [Logic Customization](logic.md) - Customizing business logic
|
|
@@ -5,7 +5,16 @@ section: customization
|
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
7
7
|
|
|
8
|
-
With Dependencies, you can
|
|
8
|
+
With Dependencies, you can replace parts of Spree core with your custom code:
|
|
9
|
+
[Services and Workflows](workflows.md), CanCanCan
|
|
10
|
+
Abilities (used for [Permissions](permissions.md)), and API Serializers (used for
|
|
11
|
+
generating JSON API responses).
|
|
12
|
+
|
|
13
|
+
> **TIP:** Replacing a whole class means keeping your copy in sync with every Spree
|
|
14
|
+
> release. If you only need to run code inside an existing flow — validating,
|
|
15
|
+
> reacting, or contributing data to a calculation — use a
|
|
16
|
+
> [hook](workflows.md#extending-a-workflow-with-hooks)
|
|
17
|
+
> instead. Hooks survive upgrades.
|
|
9
18
|
|
|
10
19
|
## Application (global) customization
|
|
11
20
|
|
|
@@ -14,50 +23,68 @@ This will change every aspect of the application (both APIs, Admin Panel, and St
|
|
|
14
23
|
In your `config/initializers/spree.rb` file, you can set the following:
|
|
15
24
|
|
|
16
25
|
```ruby
|
|
17
|
-
Spree.
|
|
26
|
+
Spree.cart_update_service = MyStore::CartUpdate
|
|
18
27
|
```
|
|
19
28
|
|
|
20
29
|
or using the block syntax:
|
|
21
30
|
|
|
22
31
|
```ruby
|
|
23
32
|
Spree.dependencies do |dependencies|
|
|
24
|
-
dependencies.
|
|
33
|
+
dependencies.cart_update_service = MyStore::CartUpdate
|
|
25
34
|
end
|
|
26
35
|
```
|
|
27
36
|
|
|
28
37
|
Now let's create your custom service.
|
|
29
38
|
|
|
30
39
|
```bash
|
|
31
|
-
mkdir -p app/services && touch app/services/
|
|
40
|
+
mkdir -p app/services/my_store && touch app/services/my_store/cart_update.rb
|
|
32
41
|
```
|
|
33
42
|
|
|
34
43
|
And add the following code to it:
|
|
35
44
|
|
|
36
45
|
```ruby
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
module MyStore
|
|
47
|
+
class CartUpdate < Spree::Carts::Update
|
|
48
|
+
def call(cart:, params:)
|
|
49
|
+
result = super
|
|
50
|
+
|
|
51
|
+
MyStore::ErpSync.push(result.value) if result.success?
|
|
52
|
+
|
|
53
|
+
result
|
|
43
54
|
end
|
|
44
55
|
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Inheriting and calling `super` keeps Spree's behaviour and adds yours around it,
|
|
60
|
+
which is usually what you want — a full rewrite means re-implementing logic that
|
|
61
|
+
changes between releases.
|
|
62
|
+
|
|
63
|
+
### Replacing a workflow
|
|
45
64
|
|
|
46
|
-
|
|
65
|
+
Workflow-backed seams end in `_workflow` (`cart_add_item_workflow`,
|
|
66
|
+
`carts_complete_workflow`, `payment_capture_workflow`, …). A replacement
|
|
67
|
+
subclasses the workflow and overrides `perform`:
|
|
47
68
|
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
```ruby
|
|
70
|
+
module MyStore
|
|
71
|
+
class AddItem < Spree::Carts::AddItem
|
|
72
|
+
def perform(variant:, cart: nil, **rest)
|
|
73
|
+
super
|
|
74
|
+
|
|
75
|
+
# your logic, then the standard result
|
|
76
|
+
end
|
|
50
77
|
end
|
|
51
78
|
end
|
|
52
|
-
```
|
|
53
79
|
|
|
54
|
-
|
|
80
|
+
Spree.cart_add_item_workflow = MyStore::AddItem
|
|
81
|
+
```
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
> **NOTE:** Before writing this, check whether a
|
|
84
|
+
> [hook](workflows.md#available-hooks) covers your case —
|
|
85
|
+
> `carts.add_item.validate` and `carts.add_item.after_item_added` handle most
|
|
86
|
+
> reasons people replace this class, and they don't need maintaining across
|
|
87
|
+
> upgrades.
|
|
61
88
|
|
|
62
89
|
## Using dependencies in your code
|
|
63
90
|
|
|
@@ -65,7 +92,7 @@ When you need to use a dependency in your code, you can access it directly via t
|
|
|
65
92
|
|
|
66
93
|
```ruby
|
|
67
94
|
# Returns the resolved class (not a string)
|
|
68
|
-
Spree.
|
|
95
|
+
Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 1)
|
|
69
96
|
|
|
70
97
|
# For API dependencies, use the Spree.api accessor
|
|
71
98
|
Spree.api.storefront_cart_serializer.new(order).serializable_hash
|
|
@@ -99,7 +126,7 @@ end
|
|
|
99
126
|
|
|
100
127
|
This will change the serializer in this API endpoint to `MyNewAwesomeCartSerializer` and also it will swap the default `add_item_service` to `MyNewAwesomeAddItemToCart`.
|
|
101
128
|
|
|
102
|
-
Different API endpoints can have different dependency injection points. You can review their [source code](https://github.com/spree/spree/tree/
|
|
129
|
+
Different API endpoints can have different dependency injection points. You can review their [source code](https://github.com/spree/spree/tree/main/api/app/controllers/spree/api/v3) to see what you can replace.
|
|
103
130
|
|
|
104
131
|
## API level customization
|
|
105
132
|
|
|
@@ -117,7 +144,7 @@ This will swap the default Cart serializer and Add Item to Cart service for your
|
|
|
117
144
|
You can mix and match both global and API-level customizations:
|
|
118
145
|
|
|
119
146
|
```ruby
|
|
120
|
-
Spree.
|
|
147
|
+
Spree.cart_add_item_workflow = MyNewAwesomeAddItemToCart
|
|
121
148
|
Spree.api.storefront_cart_add_item_service = AnotherAddItemToCart
|
|
122
149
|
```
|
|
123
150
|
|
|
@@ -137,9 +164,9 @@ This will output all dependencies with their current values:
|
|
|
137
164
|
|
|
138
165
|
```
|
|
139
166
|
[CORE]
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
167
|
+
cart_add_item_workflow Spree::Carts::AddItem
|
|
168
|
+
carts_create_service Spree::Carts::Create
|
|
169
|
+
cart_recalculate_workflow Spree::Carts::Recalculate [OVERRIDDEN]
|
|
143
170
|
...
|
|
144
171
|
|
|
145
172
|
[API]
|
|
@@ -164,10 +191,10 @@ This shows only the dependencies that have been customized, along with their ori
|
|
|
164
191
|
|
|
165
192
|
```
|
|
166
193
|
[Core OVERRIDES]
|
|
167
|
-
|
|
194
|
+
cart_recalculate_workflow Spree::Carts::Recalculate -> MyApp::CartRecalculate (config/initializers/spree.rb:15)
|
|
168
195
|
|
|
169
196
|
[API OVERRIDES]
|
|
170
|
-
storefront_cart_add_item_service Spree::
|
|
197
|
+
storefront_cart_add_item_service Spree::Carts::AddItem -> MyApp::CartAddItem (config/initializers/spree.rb:20)
|
|
171
198
|
```
|
|
172
199
|
|
|
173
200
|
### Validate all dependencies
|
|
@@ -181,7 +208,7 @@ This validates that all dependencies can be resolved to valid classes. If any de
|
|
|
181
208
|
```
|
|
182
209
|
..........F.........
|
|
183
210
|
1 invalid dependencies:
|
|
184
|
-
[Core]
|
|
211
|
+
[Core] cart_add_item_workflow: uninitialized constant NonExistentClass
|
|
185
212
|
```
|
|
186
213
|
|
|
187
214
|
## Programmatic introspection
|
|
@@ -191,14 +218,14 @@ You can also inspect dependencies programmatically:
|
|
|
191
218
|
```ruby
|
|
192
219
|
# Check all current values
|
|
193
220
|
Spree::Dependencies.current_values
|
|
194
|
-
# => [{name: :
|
|
221
|
+
# => [{name: :cart_add_item_workflow, current: MyApp::CartAddItem, default: 'Spree::Carts::AddItem', overridden: true}, ...]
|
|
195
222
|
|
|
196
223
|
# Check if a specific dependency is overridden
|
|
197
|
-
Spree::Dependencies.overridden?(:
|
|
224
|
+
Spree::Dependencies.overridden?(:cart_add_item_workflow)
|
|
198
225
|
# => true
|
|
199
226
|
|
|
200
227
|
# Get override information (where it was set)
|
|
201
|
-
Spree::Dependencies.override_info(:
|
|
228
|
+
Spree::Dependencies.override_info(:cart_add_item_workflow)
|
|
202
229
|
# => {value: MyApp::CartAddItem, source: "config/initializers/spree.rb:15", set_at: 2024-01-15 10:30:00}
|
|
203
230
|
|
|
204
231
|
# Validate all dependencies resolve to valid classes
|
|
@@ -206,18 +233,43 @@ Spree::Dependencies.validate!
|
|
|
206
233
|
# => true (or raises Spree::DependencyError)
|
|
207
234
|
```
|
|
208
235
|
|
|
236
|
+
## Renamed seams in Spree 6.0
|
|
237
|
+
|
|
238
|
+
Seams backed by a [workflow](workflows.md) were renamed
|
|
239
|
+
from `*_service` to `*_workflow` in 6.0:
|
|
240
|
+
|
|
241
|
+
| Legacy name | Current name |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `cart_add_item_service` | `cart_add_item_workflow` |
|
|
244
|
+
| `cart_recalculate_service` | `cart_recalculate_workflow` |
|
|
245
|
+
| `carts_complete_service` | `carts_complete_workflow` |
|
|
246
|
+
| `cart_merge_strategy` | `cart_merge_workflow` |
|
|
247
|
+
| `order_cancel_service` | `order_cancel_workflow` |
|
|
248
|
+
| `order_complete_service` | `order_complete_workflow` |
|
|
249
|
+
| `fulfillment_create_service` | `fulfillment_create_workflow` |
|
|
250
|
+
| `payments_handle_webhook_service` | `payments_handle_webhook_workflow` |
|
|
251
|
+
|
|
252
|
+
> **WARNING:** The legacy names stay readable for one release, but **assigning to one no longer
|
|
253
|
+
> has any effect** — the override is recorded and a deprecation warning names the
|
|
254
|
+
> seam to port to. A class written against the old service contract isn't
|
|
255
|
+
> interchangeable with the workflow the new call sites use, so applying it
|
|
256
|
+
> silently would break checkout in ways that are hard to trace.
|
|
257
|
+
>
|
|
258
|
+
> If you override any of these, move to the `*_workflow` name and make sure your
|
|
259
|
+
> class subclasses the workflow. The legacy names are removed in Spree 6.1.
|
|
260
|
+
|
|
209
261
|
## Backwards compatibility
|
|
210
262
|
|
|
211
263
|
The legacy string-based syntax is still supported for backwards compatibility:
|
|
212
264
|
|
|
213
265
|
```ruby
|
|
214
266
|
# Legacy syntax (still works)
|
|
215
|
-
Spree::Dependencies.
|
|
216
|
-
result = Spree::Dependencies.
|
|
267
|
+
Spree::Dependencies.carts_create_service = 'MyStore::CartCreate'
|
|
268
|
+
result = Spree::Dependencies.carts_create_service.constantize
|
|
217
269
|
|
|
218
270
|
# New syntax (recommended)
|
|
219
|
-
Spree.
|
|
220
|
-
result = Spree.
|
|
271
|
+
Spree.carts_create_service = MyStore::CartCreate
|
|
272
|
+
result = Spree.carts_create_service
|
|
221
273
|
```
|
|
222
274
|
|
|
223
275
|
Both syntaxes can coexist, but the new syntax is recommended as it's more concise and provides better error messages at assignment time.
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Services & Workflows
|
|
3
|
+
section: customization
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Spree's business logic lives in two kinds of plain Ruby classes. Both are called
|
|
9
|
+
the same way and both return the same result object, so as a caller you never
|
|
10
|
+
need to know which one you're using.
|
|
11
|
+
|
|
12
|
+
**Services** (`app/services/`) are the default. A service is an ordinary class
|
|
13
|
+
with a `call` method — creating a customer, updating a price, removing an item
|
|
14
|
+
from a cart. Most of Spree is services.
|
|
15
|
+
|
|
16
|
+
**Workflows** (`app/workflows/`) handle the flows that need more: completing a
|
|
17
|
+
checkout, cancelling an order, capturing a payment, creating a fulfillment.
|
|
18
|
+
These are the operations where something can go wrong halfway through, where
|
|
19
|
+
money moves, or where you might want to inject your own logic partway.
|
|
20
|
+
|
|
21
|
+
A workflow earns its place by needing at least one of:
|
|
22
|
+
|
|
23
|
+
* **Extension points** — named places where your code can run inside the flow
|
|
24
|
+
* **External calls** — payment gateways, carrier APIs, anything over the network
|
|
25
|
+
* **Compensation** — undoing earlier work when a later step fails
|
|
26
|
+
|
|
27
|
+
Everything else stays a service. If you're writing a plain create-update-delete
|
|
28
|
+
operation, write a service.
|
|
29
|
+
|
|
30
|
+
## Calling them
|
|
31
|
+
|
|
32
|
+
Identical for both:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 2)
|
|
36
|
+
|
|
37
|
+
if result.success?
|
|
38
|
+
line_item = result.value
|
|
39
|
+
else
|
|
40
|
+
puts result.error.value
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Never raise on expected failures — check `result.success?`. See
|
|
45
|
+
[Dependencies](dependencies.md) for how to swap either one
|
|
46
|
+
for your own class.
|
|
47
|
+
|
|
48
|
+
## Extending a workflow with hooks
|
|
49
|
+
|
|
50
|
+
Before hooks, customizing a flow meant replacing the whole class and keeping
|
|
51
|
+
your copy in sync with every Spree release. Hooks let you run your own code at a
|
|
52
|
+
named point inside a flow you don't own.
|
|
53
|
+
|
|
54
|
+
Register in `config/initializers/spree.rb`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The key is `<workflow>.<hook>`. Handlers are stored as **class name strings**,
|
|
61
|
+
resolved when the hook fires — that keeps registration safe at boot time and
|
|
62
|
+
survives code reloading in development. A block works for one-liners:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Spree.hooks.register('orders.cancel.after_cancel') do |workflow|
|
|
66
|
+
MyStore::Analytics.track(:order_cancelled, workflow.order.number)
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Your handler is a class with a `call` method that receives the workflow:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
module MyStore
|
|
74
|
+
class CheckPurchaseLimit
|
|
75
|
+
def call(workflow)
|
|
76
|
+
# every #perform keyword is a reader: cart, variant, quantity, ...
|
|
77
|
+
return if workflow.quantity <= 10
|
|
78
|
+
|
|
79
|
+
workflow.reject!('You can order at most 10 of this item.')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> **NOTE:** Hook keys are validated at boot. Registering against a hook that doesn't exist
|
|
86
|
+
> raises `Spree::Hooks::UnknownHookError` with the list of valid hooks for that
|
|
87
|
+
> workflow, so a typo fails immediately instead of silently never firing.
|
|
88
|
+
|
|
89
|
+
## Every hook can have many handlers
|
|
90
|
+
|
|
91
|
+
A hook is not a single slot. Your extension, another extension and the host
|
|
92
|
+
application can all register against the same key, and every one of them runs.
|
|
93
|
+
Assume you are never alone on a hook.
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
|
|
97
|
+
Spree.hooks.register('carts.add_item.validate', 'OtherGem::CheckChannelRules')
|
|
98
|
+
Spree.hooks.register('carts.add_item.validate') { |workflow| ... }
|
|
99
|
+
# all three run
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Handlers run in registration order** — the order the `register` calls
|
|
103
|
+
happened, which for gems is initializer load order. Don't depend on it. If your
|
|
104
|
+
handler only makes sense after another one has run, you have a sequencing
|
|
105
|
+
requirement that hooks don't express; put both pieces in one handler.
|
|
106
|
+
|
|
107
|
+
**Registering the same class twice is a no-op.** `register` deduplicates by
|
|
108
|
+
class name, so an initializer that runs twice (or a gem registering defensively)
|
|
109
|
+
won't double up. Two *different* classes are two handlers, and two separate
|
|
110
|
+
blocks are always two handlers — blocks can't be compared, so prefer class names
|
|
111
|
+
anywhere registration might repeat.
|
|
112
|
+
|
|
113
|
+
What "many handlers" means differs by kind:
|
|
114
|
+
|
|
115
|
+
| Kind | With several handlers |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `validate` | Runs in order until one rejects. **The first `reject!` stops the flow** — later validate handlers never run, so don't rely on yours executing. |
|
|
118
|
+
| Context | **All** run and their hashes are merged. A key set by two handlers goes to the last one registered, and the collision is reported through `Rails.error`. |
|
|
119
|
+
| Lifecycle | All run in order. Return values are ignored. |
|
|
120
|
+
|
|
121
|
+
One consequence worth planning for: because any handler can veto, a `validate`
|
|
122
|
+
handler should say *why* it rejected in the message, and a lifecycle handler
|
|
123
|
+
should not assume it is the only observer of the event.
|
|
124
|
+
|
|
125
|
+
> **WARNING:** Handlers are not isolated from each other. An exception raised in one propagates
|
|
126
|
+
> out of the workflow, later handlers on that hook never run, and an open
|
|
127
|
+
> transaction rolls back — a `raise` in an `after_item_added` handler leaves the
|
|
128
|
+
> customer's item not added at all.
|
|
129
|
+
>
|
|
130
|
+
> If your handler does something optional (analytics, a nice-to-have
|
|
131
|
+
> notification), rescue inside it so a failure in your code can't undo someone
|
|
132
|
+
> else's order. Work that is genuinely allowed to fail belongs in an
|
|
133
|
+
> [event subscriber](../core-concepts/events.md), not a hook.
|
|
134
|
+
|
|
135
|
+
## The three kinds of hooks
|
|
136
|
+
|
|
137
|
+
### Lifecycle hooks — react to something that happened
|
|
138
|
+
|
|
139
|
+
Named in the past tense (`after_item_added`, `after_cancel`, `after_create`).
|
|
140
|
+
They run after the work is done. Return values are ignored; you cannot change
|
|
141
|
+
the outcome.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class MyStore::NotifyWarehouse
|
|
145
|
+
def call(workflow)
|
|
146
|
+
WarehouseApi.notify(workflow.fulfillment.number)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
Spree.hooks.register('fulfillments.create.after_create', 'MyStore::NotifyWarehouse')
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Note that some lifecycle hooks run **inside** the flow's database transaction
|
|
154
|
+
(`after_item_added`, `after_cancel`). That's deliberate — it lets you write
|
|
155
|
+
related records atomically with the change. It also means slow work does not
|
|
156
|
+
belong there: use an [event subscriber](../core-concepts/events.md) for
|
|
157
|
+
emails, webhooks and other eventual work.
|
|
158
|
+
|
|
159
|
+
### Validation hooks — veto before the work happens
|
|
160
|
+
|
|
161
|
+
Always called `validate`. They run **before** anything is written, so rejecting
|
|
162
|
+
costs nothing — no rollback, no partial state, no money moved.
|
|
163
|
+
|
|
164
|
+
Call `reject!` on the workflow to stop the flow:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
module MyStore
|
|
168
|
+
class LimitReturnWindow
|
|
169
|
+
def call(workflow)
|
|
170
|
+
return if workflow.order.completed_at > 30.days.ago
|
|
171
|
+
|
|
172
|
+
workflow.reject!('This order is outside the 30-day return window.')
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The caller receives a normal failure result — no exception reaches your
|
|
179
|
+
controller:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant)
|
|
183
|
+
result.success? # => false
|
|
184
|
+
result.error.value # => "You can order at most 10 of this item."
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
> **WARNING:** Reject from `validate` hooks, not from `after_*` hooks. Rejecting late still
|
|
188
|
+
> rolls the database back, but it cannot undo work that already left the system —
|
|
189
|
+
> `carts.complete.before_finalize` runs *after* the customer's card was charged,
|
|
190
|
+
> so rejecting there rolls back the order while the charge stands. If you need to
|
|
191
|
+
> stop a flow, `validate` is the place.
|
|
192
|
+
|
|
193
|
+
### Context hooks — feed data into a calculation
|
|
194
|
+
|
|
195
|
+
Named imperatively (`set_promotion_context`, `set_tax_line_context`,
|
|
196
|
+
`get_provider_data`). They run before a calculation so you can contribute data
|
|
197
|
+
to it.
|
|
198
|
+
|
|
199
|
+
Your handler **returns a hash**. Spree merges the hashes from every registered
|
|
200
|
+
handler and hands the result to the workflow:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
module MyStore
|
|
204
|
+
class TaxExemption
|
|
205
|
+
def call(workflow)
|
|
206
|
+
certificate = workflow.cart.customer&.tax_exemption_certificate
|
|
207
|
+
return {} if certificate.blank?
|
|
208
|
+
|
|
209
|
+
{ exemption_certificate: certificate.number }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyStore::TaxExemption')
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Handlers stay independent — there is no shared object to mutate and no ordering
|
|
218
|
+
to reason about. A handler returning anything other than a hash contributes
|
|
219
|
+
nothing, so lifecycle-style handlers are harmless if registered here by mistake.
|
|
220
|
+
|
|
221
|
+
If two handlers set the same key, the last registered one wins and the collision
|
|
222
|
+
is reported through `Rails.error` so it's visible rather than mysterious.
|
|
223
|
+
|
|
224
|
+
## Available hooks
|
|
225
|
+
|
|
226
|
+
| Workflow key | Hook | Kind | When it runs |
|
|
227
|
+
|---|---|---|---|
|
|
228
|
+
| `carts.add_item` | `validate` | validate | Before the line item is built |
|
|
229
|
+
| `carts.add_item` | `after_item_added` | lifecycle | After the item is saved and totals recalculated (in transaction) |
|
|
230
|
+
| `carts.complete` | `validate` | validate | After checkout requirements pass, before the order is created |
|
|
231
|
+
| `carts.complete` | `before_finalize` | lifecycle | After payment, before the order is placed |
|
|
232
|
+
| `carts.complete` | `after_finalize` | lifecycle | After the order is placed |
|
|
233
|
+
| `carts.merge` | `validate` | validate | Before any items move between carts |
|
|
234
|
+
| `carts.merge` | `after_merge` | lifecycle | After the carts are folded together |
|
|
235
|
+
| `carts.recalculate` | `set_promotion_context` | context | Before promotions are evaluated |
|
|
236
|
+
| `carts.recalculate` | `after_recalculate` | lifecycle | After the cart is fully repriced |
|
|
237
|
+
| `carts.recalculate_totals` | `set_tax_line_context` | context | Before tax is estimated |
|
|
238
|
+
| `orders.cancel` | `before_cancel` | validate | Before the cancellation is recorded |
|
|
239
|
+
| `orders.cancel` | `after_cancel` | lifecycle | With the cancellation, in transaction |
|
|
240
|
+
| `orders.resume` | `before_resume` | validate | Before the order is un-cancelled |
|
|
241
|
+
| `orders.resume` | `after_resume` | lifecycle | With the status flip, in transaction |
|
|
242
|
+
| `fulfillments.create` | `validate` | validate | Before the order is locked |
|
|
243
|
+
| `fulfillments.create` | `get_provider_data` | context | Before the fulfillment is built |
|
|
244
|
+
| `fulfillments.create` | `after_create` | lifecycle | After the fulfillment is created and totals recalculated |
|
|
245
|
+
| `payments.capture` | `validate` | validate | Before the gateway is called |
|
|
246
|
+
| `payments.capture` | `before_capture` | lifecycle | Immediately before the gateway call |
|
|
247
|
+
| `payments.capture` | `after_capture` | lifecycle | After a successful capture |
|
|
248
|
+
| `payments.refund` | `validate` | validate | Before the refund record exists |
|
|
249
|
+
| `payments.refund` | `before_refund` | lifecycle | Immediately before the gateway call |
|
|
250
|
+
| `payments.refund` | `after_refund` | lifecycle | After a successful refund |
|
|
251
|
+
| `payments.handle_webhook` | `after_handle` | lifecycle | After the gateway callback is processed |
|
|
252
|
+
|
|
253
|
+
`before_cancel` and `before_resume` accept `reject!` like a `validate` hook.
|
|
254
|
+
|
|
255
|
+
Draft-order editing in the admin uses **twin workflows** with their own keys —
|
|
256
|
+
`orders.add_item`, `orders.recalculate`, `orders.recalculate_totals` — carrying
|
|
257
|
+
the same hooks as their cart counterparts. Register against the cart key for
|
|
258
|
+
storefront carts, the order key for admin edits, or both.
|
|
259
|
+
|
|
260
|
+
Inspect what's available at runtime:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
Spree.hooks.workflows # => { 'carts.add_item' => 'Spree::Carts::AddItem', ... }
|
|
264
|
+
Spree::Carts::Complete.declared_hooks # => [:validate, :before_finalize, :after_finalize]
|
|
265
|
+
Spree.hooks.keys # => registered keys
|
|
266
|
+
Spree.hooks.validate! # => true, or raises on a bad registration
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Writing your own workflow
|
|
270
|
+
|
|
271
|
+
Most extensions only need hooks. Write a workflow when you're adding a *new*
|
|
272
|
+
multi-step operation of your own — one with external calls, compensation, or
|
|
273
|
+
extension points for others.
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
module MyStore
|
|
277
|
+
class Subscriptions::Renew < Spree::Workflow
|
|
278
|
+
hooks :validate, :after_renew
|
|
279
|
+
|
|
280
|
+
attr_reader :order
|
|
281
|
+
|
|
282
|
+
# The method signature is the contract — Ruby raises on a missing or
|
|
283
|
+
# unknown keyword, and a bare `super` turns each parameter into a reader.
|
|
284
|
+
#
|
|
285
|
+
# @param subscription [MyStore::Subscription]
|
|
286
|
+
# @param renewed_at [Time, nil]
|
|
287
|
+
def perform(subscription:, renewed_at: nil)
|
|
288
|
+
super
|
|
289
|
+
|
|
290
|
+
step :ensure_renewable
|
|
291
|
+
run_hooks :validate
|
|
292
|
+
|
|
293
|
+
ApplicationRecord.transaction do
|
|
294
|
+
step :build_order, on_flow_failure: :discard_order
|
|
295
|
+
step :extend_period
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
external_step :charge_customer
|
|
299
|
+
|
|
300
|
+
run_hooks :after_renew
|
|
301
|
+
subscription.publish_event('subscription.renewed')
|
|
302
|
+
success(order)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def ensure_renewable
|
|
308
|
+
failure(subscription, :not_active) unless subscription.active?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_order
|
|
312
|
+
@order = MyStore::Subscriptions::BuildOrder.call(subscription: subscription).value
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def extend_period
|
|
316
|
+
subscription.update!(renews_at: (renewed_at || Time.current) + 1.month)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def charge_customer
|
|
320
|
+
Spree.payment_capture_workflow.call(payment: order.payments.last)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Runs if a later step fails after the transaction committed.
|
|
324
|
+
def discard_order
|
|
325
|
+
order&.destroy
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The whole vocabulary:
|
|
332
|
+
|
|
333
|
+
| | |
|
|
334
|
+
|---|---|
|
|
335
|
+
| `step :name` | Runs the private method of that name |
|
|
336
|
+
| `external_step :name` | Same, but refuses to run inside a database transaction this workflow opened — use it for every network call |
|
|
337
|
+
| `with: -> { ... }` | Delegates a step to a swappable collaborator, keyword arguments sliced from the workflow's readers |
|
|
338
|
+
| `on_flow_failure: :name` | Names the undo for a step, run in reverse if a later step fails |
|
|
339
|
+
| `run_hooks :name` | Dispatches a declared hook; returns the merged hash from context handlers |
|
|
340
|
+
| `failure(value, error)` | Aborts the flow — rolls back an open transaction and returns a failure result |
|
|
341
|
+
| `reject!(message)` | The same, named for hook handlers vetoing a flow |
|
|
342
|
+
| `halt!(value)` | Successful early exit (not valid inside a transaction the workflow opened) |
|
|
343
|
+
| `hooks :a, :b` | Declares the extension points this workflow dispatches |
|
|
344
|
+
|
|
345
|
+
Everything else is ordinary Rails — `ApplicationRecord.transaction`,
|
|
346
|
+
`with_lock`, `if`, `rescue`, `publish_event`.
|
|
347
|
+
|
|
348
|
+
Two rules worth internalising:
|
|
349
|
+
|
|
350
|
+
**Network calls never share a database transaction.** That's what
|
|
351
|
+
`external_step` enforces. A gateway call inside a transaction holds a database
|
|
352
|
+
connection open across a network round trip, and a timeout leaves your database
|
|
353
|
+
and the payment processor disagreeing about what happened.
|
|
354
|
+
|
|
355
|
+
**New models get a plain `status` string, not a state machine.** Transitions are
|
|
356
|
+
workflows: `MyStore::Subscriptions::Cancel.call(...)`, not `subscription.cancel!`.
|
|
357
|
+
Transition callbacks hide side effects inside a save, cannot take arguments, and
|
|
358
|
+
have no compensation story.
|
|
359
|
+
|
|
360
|
+
## Observability
|
|
361
|
+
|
|
362
|
+
Every step emits an `ActiveSupport::Notifications` event, so your APM sees the
|
|
363
|
+
flow without extra instrumentation:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
ActiveSupport::Notifications.subscribe('step.spree_workflow') do |*, payload|
|
|
367
|
+
Rails.logger.info("#{payload[:workflow]}##{payload[:step]}")
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Choosing an extension point
|
|
372
|
+
|
|
373
|
+
| You want to | Use |
|
|
374
|
+
|---|---|
|
|
375
|
+
| Stop an operation from happening | A `validate` hook |
|
|
376
|
+
| Add data to a pricing or tax calculation | A context hook |
|
|
377
|
+
| Do something after an operation, in the same transaction | A lifecycle hook |
|
|
378
|
+
| Send an email, call a webhook, update a search index | An [event subscriber](../core-concepts/events.md) |
|
|
379
|
+
| Replace an operation entirely | [Dependencies](dependencies.md) |
|
|
380
|
+
| Add a brand-new multi-step operation | Your own workflow |
|
|
381
|
+
|
|
382
|
+
Reach for the smallest one that does the job. A hook survives Spree upgrades;
|
|
383
|
+
a replaced class has to be kept in sync with every release.
|