@spree/docs 0.1.181 → 0.1.183
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/seller-api/errors.md +1 -1
- package/dist/api-reference/store.yaml +78 -91
- package/dist/developer/core-concepts/addresses.md +106 -198
- package/dist/developer/core-concepts/architecture.md +97 -126
- package/dist/developer/core-concepts/calculators.md +75 -252
- package/dist/developer/core-concepts/carts.md +1 -1
- package/dist/developer/core-concepts/channels.md +0 -4
- package/dist/developer/core-concepts/companies-and-catalogs.md +1 -1
- package/dist/developer/core-concepts/customers.md +0 -3
- package/dist/developer/core-concepts/discounts.md +133 -0
- package/dist/developer/core-concepts/events.md +83 -576
- package/dist/developer/core-concepts/fees.md +144 -0
- package/dist/developer/core-concepts/imports-exports.md +105 -679
- package/dist/developer/core-concepts/inventory.md +114 -248
- package/dist/developer/core-concepts/markets.md +9 -12
- package/dist/developer/core-concepts/media.md +9 -11
- package/dist/developer/core-concepts/metafields.md +123 -200
- package/dist/developer/core-concepts/order-totals.md +110 -0
- package/dist/developer/core-concepts/orders.md +1 -1
- package/dist/developer/core-concepts/payments.md +11 -14
- package/dist/developer/core-concepts/pricing.md +11 -13
- package/dist/developer/core-concepts/products.md +173 -19
- package/dist/developer/core-concepts/promotions.md +12 -11
- package/dist/developer/core-concepts/search-filtering.md +2 -4
- package/dist/developer/core-concepts/store-credits-gift-cards.md +0 -3
- package/dist/developer/core-concepts/taxes.md +125 -113
- package/dist/developer/core-concepts/translations.md +61 -68
- package/dist/developer/core-concepts/webhooks.md +25 -59
- package/dist/developer/how-to/custom-promotion.md +3 -3
- package/package.json +1 -1
- package/dist/developer/core-concepts/taxes-discounts-fees.md +0 -199
|
@@ -1,644 +1,151 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Events
|
|
3
|
-
description:
|
|
3
|
+
description: Reacting to things that happen in Spree — an order placed, a payment taken, stock running out — without modifying core code.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
import { Since } from '/snippets/since.mdx';
|
|
7
|
-
|
|
8
|
-
|
|
9
6
|
## Overview
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
This pattern enables loose coupling between components and makes it easy to:
|
|
14
|
-
|
|
15
|
-
- Send email notifications when orders are placed
|
|
16
|
-
- Sync data with external services when products change
|
|
17
|
-
- Log audit trails for compliance
|
|
18
|
-
- Trigger webhooks to notify third-party systems
|
|
19
|
-
- Update caches when inventory changes
|
|
20
|
-
|
|
21
|
-
## How Events Work
|
|
22
|
-
|
|
23
|
-
Spree's event system provides a clean API through:
|
|
8
|
+
Something happens in a store — an order is placed, a payment clears, a product sells out — and you want something else to happen: notify a warehouse, post to a channel, update a spreadsheet.
|
|
24
9
|
|
|
25
|
-
|
|
26
|
-
2. **`Spree::Subscriber`** - Base class for creating event subscribers
|
|
27
|
-
3. **`Spree::Publishable`** - Concern that enables models to publish events
|
|
28
|
-
|
|
29
|
-
When an event is published, all matching subscribers are notified. By default, subscribers run asynchronously via background jobs to avoid blocking the main request.
|
|
10
|
+
Events are how you attach that behaviour without editing Spree. Spree announces what happened; your code decides what to do about it.
|
|
30
11
|
|
|
31
12
|
```mermaid
|
|
32
|
-
flowchart
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
C --> D[Spree::Events]
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
subgraph Event Adapter
|
|
40
|
-
D --> E[Find Matching Subscribers]
|
|
41
|
-
E --> F{Async?}
|
|
42
|
-
F -->|Yes| G[Queue Background Job]
|
|
43
|
-
F -->|No| H[Execute Immediately]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
subgraph Subscribers
|
|
47
|
-
G --> I[SubscriberJob]
|
|
48
|
-
I --> J[Your Subscriber]
|
|
49
|
-
H --> J
|
|
50
|
-
J --> K[Send Email]
|
|
51
|
-
J --> L[Sync External Service]
|
|
52
|
-
J --> M[Update Cache]
|
|
53
|
-
J --> N[Trigger Webhook]
|
|
54
|
-
end
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Creating a Subscriber
|
|
58
|
-
|
|
59
|
-
The fastest path is the generator — it creates the class, a spec stub, and registers the subscriber in `config/initializers/spree.rb` (the step that's easy to forget):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```bash Spree CLI (Docker)
|
|
63
|
-
spree generate subscriber OrderCompleted order.completed
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
```bash Without Spree CLI
|
|
67
|
-
bin/rails g spree:subscriber OrderCompleted order.completed
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Or create the class by hand in `app/subscribers/`, inheriting from `Spree::Subscriber`:
|
|
72
|
-
|
|
73
|
-
```ruby app/subscribers/order_completed_subscriber.rb
|
|
74
|
-
class OrderCompletedSubscriber < Spree::Subscriber
|
|
75
|
-
subscribes_to 'order.completed'
|
|
76
|
-
|
|
77
|
-
def handle(event)
|
|
78
|
-
order_id = event.payload['id']
|
|
79
|
-
order = Spree::Order.find_by_prefix_id(order_id)
|
|
80
|
-
return unless order
|
|
81
|
-
|
|
82
|
-
# Your custom logic here
|
|
83
|
-
ExternalService.notify_order_placed(order)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
13
|
+
flowchart LR
|
|
14
|
+
Action["Order is placed"] --> Event["order.placed"]
|
|
15
|
+
Event --> Sub["Your subscriber<br/>(in-app)"]
|
|
16
|
+
Event --> Hook["Webhook<br/>(external system)"]
|
|
86
17
|
```
|
|
87
18
|
|
|
88
|
-
|
|
19
|
+
There are two ways to listen, and which you want depends on where your code lives:
|
|
89
20
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### Subscriber DSL
|
|
97
|
-
|
|
98
|
-
The `Spree::Subscriber` class provides a clean DSL for declaring subscriptions:
|
|
99
|
-
|
|
100
|
-
```ruby
|
|
101
|
-
class MySubscriber < Spree::Subscriber
|
|
102
|
-
# Subscribe to a single event
|
|
103
|
-
subscribes_to 'order.completed'
|
|
104
|
-
|
|
105
|
-
# Subscribe to multiple events
|
|
106
|
-
subscribes_to 'order.completed', 'order.canceled', 'order.resumed'
|
|
107
|
-
|
|
108
|
-
# Subscribe to all events matching a pattern
|
|
109
|
-
subscribes_to 'order.*' # All order events
|
|
110
|
-
subscribes_to '*.*' # All events (use sparingly!)
|
|
111
|
-
|
|
112
|
-
# Run synchronously instead of via background job
|
|
113
|
-
subscribes_to 'order.completed', async: false
|
|
114
|
-
end
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### Handling Multiple Events
|
|
118
|
-
|
|
119
|
-
When subscribing to multiple events, use the `on` DSL to route events to specific methods:
|
|
120
|
-
|
|
121
|
-
```ruby app/subscribers/order_audit_subscriber.rb
|
|
122
|
-
class OrderAuditSubscriber < Spree::Subscriber
|
|
123
|
-
subscribes_to 'order.completed', 'order.canceled', 'order.resumed'
|
|
124
|
-
|
|
125
|
-
on 'order.completed', :log_order_completed
|
|
126
|
-
on 'order.canceled', :log_order_canceled
|
|
127
|
-
on 'order.resumed', :log_order_resumed
|
|
128
|
-
|
|
129
|
-
private
|
|
130
|
-
|
|
131
|
-
def log_order_completed(event)
|
|
132
|
-
create_audit_log(event, 'completed')
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def log_order_canceled(event)
|
|
136
|
-
create_audit_log(event, 'canceled')
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def log_order_resumed(event)
|
|
140
|
-
create_audit_log(event, 'resumed')
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def create_audit_log(event, action)
|
|
144
|
-
AuditLog.create!(
|
|
145
|
-
resource_type: 'Spree::Order',
|
|
146
|
-
resource_id: event.payload['id'],
|
|
147
|
-
action: action,
|
|
148
|
-
occurred_at: event.created_at
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
## Working with Events
|
|
155
|
-
|
|
156
|
-
### Event Object
|
|
157
|
-
|
|
158
|
-
When your subscriber receives an event, you get a `Spree::Event` object with:
|
|
159
|
-
|
|
160
|
-
```ruby
|
|
161
|
-
def handle(event)
|
|
162
|
-
event.id # => "550e8400-e29b-41d4-a716-446655440000" (UUID)
|
|
163
|
-
event.name # => "order.completed"
|
|
164
|
-
event.store_id # => 1 (ID of the store where the event originated)
|
|
165
|
-
event.payload # => { "id" => 1, "number" => "R123456", ... }
|
|
166
|
-
event.metadata # => { "spree_version" => "<spree_version>" }
|
|
167
|
-
event.created_at # => Time when event was published
|
|
168
|
-
|
|
169
|
-
# Helper methods
|
|
170
|
-
event.store # => Spree::Store instance (lazy loaded)
|
|
171
|
-
event.resource_type # => "order" (extracted from name)
|
|
172
|
-
event.action # => "completed" (extracted from name)
|
|
173
|
-
end
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### Finding the Record
|
|
177
|
-
|
|
178
|
-
The payload contains serialized attributes, not the actual record. To get the record:
|
|
179
|
-
|
|
180
|
-
```ruby
|
|
181
|
-
def handle(event)
|
|
182
|
-
record_id = event.payload['id']
|
|
183
|
-
record = Spree::Order.find_by_prefix_id(record_id)
|
|
184
|
-
return unless record
|
|
185
|
-
|
|
186
|
-
# Work with the record
|
|
187
|
-
end
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
> **WARNING:** For destroy events, the record no longer exists in the database. Use the payload data instead, or capture what you need before deletion.
|
|
191
|
-
|
|
192
|
-
## Available Events
|
|
193
|
-
|
|
194
|
-
### Lifecycle Events
|
|
195
|
-
|
|
196
|
-
Models that include `Spree::Publishable` and call `publishes_lifecycle_events` automatically publish:
|
|
197
|
-
|
|
198
|
-
| Event Pattern | Description |
|
|
199
|
-
|---------------|-------------|
|
|
200
|
-
| `{model}.created` | Record was created |
|
|
201
|
-
| `{model}.updated` | Record was updated |
|
|
202
|
-
| `{model}.deleted` | Record was deleted |
|
|
203
|
-
|
|
204
|
-
For example, `Spree::Price` publishes `price.created`, `price.updated`, and `price.deleted`.
|
|
205
|
-
|
|
206
|
-
Models with lifecycle events enabled include: `Order`, `Payment`, `Price`, `Shipment`, `Variant`, `LineItem`, `StockItem`, and many others.
|
|
207
|
-
|
|
208
|
-
### Order Events
|
|
209
|
-
|
|
210
|
-
| Event | Description |
|
|
211
|
-
|-------|-------------|
|
|
212
|
-
| `order.created` | Order was created |
|
|
213
|
-
| `order.updated` | Order was updated |
|
|
214
|
-
| `order.completed` | Order checkout completed |
|
|
215
|
-
| `order.canceled` | Order was canceled |
|
|
216
|
-
| `order.resumed` | Canceled order was resumed |
|
|
217
|
-
| `order.paid` | Order is fully paid |
|
|
218
|
-
| `order.shipped` | All order shipments are shipped |
|
|
219
|
-
|
|
220
|
-
### Shipment Events
|
|
221
|
-
|
|
222
|
-
| Event | Description |
|
|
223
|
-
|-------|-------------|
|
|
224
|
-
| `shipment.created` | Shipment was created |
|
|
225
|
-
| `shipment.updated` | Shipment was updated |
|
|
226
|
-
| `shipment.shipped` | Shipment was shipped |
|
|
227
|
-
| `shipment.canceled` | Shipment was canceled |
|
|
228
|
-
| `shipment.resumed` | Shipment was resumed |
|
|
229
|
-
|
|
230
|
-
### Payment Events
|
|
231
|
-
|
|
232
|
-
| Event | Description |
|
|
233
|
-
|-------|-------------|
|
|
234
|
-
| `payment.created` | Payment was created |
|
|
235
|
-
| `payment.updated` | Payment was updated |
|
|
236
|
-
| `payment.paid` | Payment was completed |
|
|
237
|
-
|
|
238
|
-
### Price Events
|
|
239
|
-
|
|
240
|
-
| Event | Description |
|
|
241
|
-
|-------|-------------|
|
|
242
|
-
| `price.created` | Price was created |
|
|
243
|
-
| `price.updated` | Price was updated |
|
|
244
|
-
| `price.deleted` | Price was deleted |
|
|
245
|
-
|
|
246
|
-
### User Events
|
|
247
|
-
|
|
248
|
-
| Event | Description |
|
|
249
|
-
|-------|-------------|
|
|
250
|
-
| `user.created` | User was created |
|
|
251
|
-
| `user.updated` | User was updated |
|
|
252
|
-
| `user.deleted` | User was deleted |
|
|
253
|
-
|
|
254
|
-
When `Spree.admin_user_class` differs from `Spree.user_class`, admin users publish the equivalent `admin.*` events (see the Admin Events table below).
|
|
255
|
-
|
|
256
|
-
### Admin Events
|
|
257
|
-
|
|
258
|
-
| Event | Description |
|
|
259
|
-
|-------|-------------|
|
|
260
|
-
| `admin.created` | Admin user was created |
|
|
261
|
-
| `admin.updated` | Admin user was updated |
|
|
262
|
-
| `admin.deleted` | Admin user was deleted |
|
|
263
|
-
|
|
264
|
-
### Product Events
|
|
265
|
-
|
|
266
|
-
| Event | Description |
|
|
267
|
-
|-------|-------------|
|
|
268
|
-
| `product.activated` | Product status changed to active |
|
|
269
|
-
| `product.archived` | Product status changed to archived |
|
|
270
|
-
| `product.out_of_stock` | Product has no stock left for any variant |
|
|
271
|
-
| `product.back_in_stock` | Product was out of stock and now has stock again |
|
|
272
|
-
|
|
273
|
-
## Publishing Custom Events
|
|
274
|
-
|
|
275
|
-
You can publish custom events from anywhere in your application:
|
|
276
|
-
|
|
277
|
-
### From a Model
|
|
278
|
-
|
|
279
|
-
Models including `Spree::Publishable` can use `publish_event`:
|
|
280
|
-
|
|
281
|
-
```ruby
|
|
282
|
-
class Spree::Order < Spree.base_class
|
|
283
|
-
def mark_as_fraudulent!
|
|
284
|
-
update!(fraudulent: true)
|
|
285
|
-
publish_event('order.marked_fraudulent')
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
```
|
|
21
|
+
| | Use it when |
|
|
22
|
+
|---|---|
|
|
23
|
+
| **[Webhooks](webhooks.md)** | The thing reacting is a separate system — a Next.js app, an ERP, an automation tool |
|
|
24
|
+
| **Subscribers** | The reaction belongs inside the Spree application itself |
|
|
289
25
|
|
|
290
|
-
|
|
26
|
+
If you're building a headless storefront, **webhooks are almost certainly what you want**. Subscribers are for when you're running Spree as your own application and want to add behaviour to it.
|
|
291
27
|
|
|
292
|
-
|
|
28
|
+
## What Spree announces
|
|
293
29
|
|
|
294
|
-
|
|
295
|
-
Spree::Events.publish(
|
|
296
|
-
'inventory.low_stock',
|
|
297
|
-
{ variant_id: variant.id, quantity: variant.total_on_hand }
|
|
298
|
-
)
|
|
299
|
-
```
|
|
30
|
+
Most records announce their own lifecycle:
|
|
300
31
|
|
|
301
|
-
|
|
32
|
+
| Event | When |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `<resource>.created` | A record is created |
|
|
35
|
+
| `<resource>.updated` | It changes |
|
|
36
|
+
| `<resource>.deleted` | It's removed |
|
|
302
37
|
|
|
303
|
-
|
|
38
|
+
Those exist for orders, carts, products, variants, customers, payments, fulfillments, returns, media, price changes and more.
|
|
304
39
|
|
|
305
|
-
|
|
40
|
+
On top of that, meaningful business moments get their own events:
|
|
306
41
|
|
|
307
|
-
|
|
42
|
+
| Event | Meaning |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `order.placed` | A customer completed checkout |
|
|
45
|
+
| `order.paid` | Payment is settled in full |
|
|
46
|
+
| `order.canceled` | The order was cancelled |
|
|
47
|
+
| `order.shipped` / `order.delivered` | Everything has shipped / arrived |
|
|
48
|
+
| `payment.completed` / `payment.voided` | A payment succeeded / was released |
|
|
49
|
+
| `fulfillment.shipped` / `fulfillment.canceled` | A parcel went out / was stood down |
|
|
50
|
+
| `product.out_of_stock` / `product.back_in_stock` | Availability flipped |
|
|
51
|
+
| `return.received` / `return.refunded` | A return arrived / was refunded |
|
|
52
|
+
| `import.completed` / `export.completed` | Bulk work finished |
|
|
308
53
|
|
|
309
|
-
|
|
310
|
-
- `Spree::Product` → `Spree::Api::V3::ProductSerializer`
|
|
311
|
-
- `Spree::Payment` → `Spree::Api::V3::PaymentSerializer`
|
|
54
|
+
The distinction matters when choosing what to listen for. `order.updated` fires whenever anything about an order changes — including an admin editing a note. `order.placed` fires once, when a customer actually bought something. Sending a confirmation email on the wrong one is how customers receive nine copies.
|
|
312
55
|
|
|
313
|
-
|
|
56
|
+
## What an event carries
|
|
314
57
|
|
|
315
|
-
|
|
58
|
+
An event carries the record it's about, serialized:
|
|
316
59
|
|
|
317
60
|
```json
|
|
318
|
-
{
|
|
61
|
+
{
|
|
62
|
+
"id": "or_86Rf07xd4z",
|
|
63
|
+
"number": "R123456789",
|
|
64
|
+
"status": "placed",
|
|
65
|
+
"payment_status": "paid",
|
|
66
|
+
"total": "135.60",
|
|
67
|
+
"email": "customer@example.com"
|
|
68
|
+
}
|
|
319
69
|
```
|
|
320
70
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
Spree includes V3 serializers for all core models in [`api/app/serializers/spree/api/v3/`](https://github.com/spree/spree/tree/main/api/app/serializers/spree/api/v3):
|
|
324
|
-
|
|
325
|
-
| Serializer | Model |
|
|
326
|
-
|------------|-------|
|
|
327
|
-
| `OrderSerializer` | Orders with totals, statuses, nested line items, fulfillments, payments, addresses |
|
|
328
|
-
| `ProductSerializer` | Products with pricing, stock status, availability |
|
|
329
|
-
| `PaymentSerializer` | Payments with amounts, states, nested payment method and source |
|
|
330
|
-
| `FulfillmentSerializer` | Fulfillments (shipments) with tracking, nested delivery method and delivery rates |
|
|
331
|
-
| `LineItemSerializer` | Line items with quantity, pricing, nested option values |
|
|
332
|
-
| `VariantSerializer` | Variants with SKU, pricing, nested option values |
|
|
333
|
-
| `PriceSerializer` | Prices with amounts, currency, price list |
|
|
334
|
-
| ... | [And many more](https://github.com/spree/spree/tree/main/api/app/serializers/spree/api/v3) |
|
|
71
|
+
IDs are the same prefixed IDs the API uses, so you can take an ID out of an event and fetch the full record without translating anything.
|
|
335
72
|
|
|
336
|
-
|
|
73
|
+
## Reacting in another system
|
|
337
74
|
|
|
338
|
-
|
|
75
|
+
Point a webhook at your endpoint and subscribe it to the events you care about. That's covered fully in [Webhooks](webhooks.md) — including signature verification, which you should not skip.
|
|
339
76
|
|
|
340
|
-
|
|
341
|
-
- **`currency`** — Uses `Spree::Current.currency` (with full fallback chain)
|
|
342
|
-
- **`user: nil`** — Events never include user-specific pricing
|
|
343
|
-
- **`includes: []`** — Conditional associations are not included in event payloads
|
|
77
|
+
## Reacting inside Spree
|
|
344
78
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
### Overriding Event Serializers
|
|
348
|
-
|
|
349
|
-
To customize the payload for existing events, create a custom V3 serializer and configure it via [dependencies](../customization/dependencies.md):
|
|
350
|
-
|
|
351
|
-
```ruby app/serializers/my_app/order_serializer.rb
|
|
352
|
-
module MyApp
|
|
353
|
-
class OrderSerializer < Spree::Api::V3::OrderSerializer
|
|
354
|
-
# Add custom attributes
|
|
355
|
-
attribute :loyalty_points do |order|
|
|
356
|
-
(order.total.to_f * 10).to_i
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
attribute :custom_field do |order|
|
|
360
|
-
order.custom_field
|
|
361
|
-
end
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
```
|
|
79
|
+
A subscriber is a small class that names the events it wants and does something when one arrives.
|
|
365
80
|
|
|
366
|
-
```
|
|
367
|
-
|
|
81
|
+
```bash
|
|
82
|
+
bin/rails g spree:subscriber OrderPlaced order.placed
|
|
368
83
|
```
|
|
369
84
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
### Serializers for Custom Models
|
|
85
|
+
The generator writes the class, a test, and — the step that's easy to forget — registers it.
|
|
373
86
|
|
|
374
|
-
|
|
87
|
+
```ruby app/subscribers/order_placed_subscriber.rb
|
|
88
|
+
class OrderPlacedSubscriber < Spree::Subscriber
|
|
89
|
+
subscribes_to 'order.placed'
|
|
375
90
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
publishes_lifecycle_events
|
|
380
|
-
|
|
381
|
-
def renew!
|
|
382
|
-
update!(renewed_at: Time.current)
|
|
383
|
-
publish_event('subscription.renewed')
|
|
384
|
-
end
|
|
385
|
-
end
|
|
386
|
-
end
|
|
387
|
-
```
|
|
91
|
+
def handle(event)
|
|
92
|
+
order = Spree::Order.find_by_prefix_id(event.payload['id'])
|
|
93
|
+
return unless order
|
|
388
94
|
|
|
389
|
-
|
|
390
|
-
module Spree
|
|
391
|
-
module Api
|
|
392
|
-
module V3
|
|
393
|
-
class SubscriptionSerializer < BaseSerializer
|
|
394
|
-
typelize plan_name: :string, status: :string,
|
|
395
|
-
user_id: [:string, nullable: true],
|
|
396
|
-
renewed_at: [:string, nullable: true],
|
|
397
|
-
expires_at: [:string, nullable: true]
|
|
398
|
-
|
|
399
|
-
attributes :plan_name, :status,
|
|
400
|
-
renewed_at: :iso8601, expires_at: :iso8601,
|
|
401
|
-
created_at: :iso8601, updated_at: :iso8601
|
|
402
|
-
|
|
403
|
-
attribute :user_id do |subscription|
|
|
404
|
-
subscription.user&.prefixed_id
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
end
|
|
95
|
+
WarehouseClient.new.submit(order)
|
|
408
96
|
end
|
|
409
97
|
end
|
|
410
98
|
```
|
|
411
99
|
|
|
412
|
-
|
|
100
|
+
A subscriber can listen to several events, or to a whole family:
|
|
413
101
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
```ruby config/initializers/spree.rb
|
|
419
|
-
Rails.application.config.after_initialize do
|
|
420
|
-
Spree.subscribers << CustomSubscriber
|
|
421
|
-
end
|
|
102
|
+
```ruby
|
|
103
|
+
subscribes_to 'order.placed', 'order.canceled'
|
|
104
|
+
subscribes_to 'order.*'
|
|
422
105
|
```
|
|
423
106
|
|
|
424
|
-
|
|
107
|
+
Subscribers must be registered — they aren't discovered automatically, because a subscriber that starts running because of where its file sits is hard to reason about:
|
|
425
108
|
|
|
426
109
|
```ruby config/initializers/spree.rb
|
|
427
110
|
Rails.application.config.after_initialize do
|
|
428
|
-
Spree.subscribers
|
|
111
|
+
Spree.subscribers << OrderPlacedSubscriber
|
|
429
112
|
end
|
|
430
113
|
```
|
|
431
114
|
|
|
432
|
-
|
|
115
|
+
### Subscribers run in the background
|
|
433
116
|
|
|
434
|
-
By default
|
|
117
|
+
By default a subscriber runs as a background job, so a slow API call in your code doesn't slow down the customer's checkout — and a failure doesn't roll back their order.
|
|
435
118
|
|
|
436
|
-
|
|
119
|
+
That's almost always what you want. If you genuinely need to run inside the same transaction, you can ask to:
|
|
437
120
|
|
|
438
121
|
```ruby
|
|
439
|
-
|
|
440
|
-
subscribes_to 'order.completed', async: false
|
|
441
|
-
|
|
442
|
-
def handle(event)
|
|
443
|
-
# This runs immediately, blocking the request
|
|
444
|
-
end
|
|
445
|
-
end
|
|
122
|
+
subscribes_to 'order.placed', async: false
|
|
446
123
|
```
|
|
447
124
|
|
|
448
|
-
> **WARNING:**
|
|
125
|
+
> **WARNING:** A synchronous subscriber runs while the customer waits, and an exception in it can fail their checkout. Reserve it for work that must be atomic with the order, and keep it fast.
|
|
449
126
|
|
|
450
|
-
##
|
|
127
|
+
## Publishing your own events
|
|
451
128
|
|
|
452
|
-
|
|
129
|
+
Anything in your own code can announce something, and subscribers and webhooks treat it like any built-in event:
|
|
453
130
|
|
|
454
131
|
```ruby
|
|
455
|
-
|
|
456
|
-
# Events published in this block won't trigger subscribers
|
|
457
|
-
order.complete!
|
|
458
|
-
end
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
This is useful for:
|
|
462
|
-
- Data migrations where you don't want to trigger side effects
|
|
463
|
-
- Test setup where subscribers would interfere
|
|
464
|
-
- Bulk operations where individual events would be too noisy
|
|
465
|
-
|
|
466
|
-
## Testing Subscribers
|
|
467
|
-
|
|
468
|
-
### Testing Event Handling
|
|
469
|
-
|
|
470
|
-
```ruby spec/subscribers/order_completed_subscriber_spec.rb
|
|
471
|
-
require 'spec_helper'
|
|
472
|
-
|
|
473
|
-
RSpec.describe OrderCompletedSubscriber do
|
|
474
|
-
let(:order) { create(:completed_order_with_totals) }
|
|
475
|
-
let(:event) do
|
|
476
|
-
Spree::Event.new(
|
|
477
|
-
name: 'order.completed',
|
|
478
|
-
payload: order.event_payload
|
|
479
|
-
)
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
describe '#handle' do
|
|
483
|
-
it 'notifies external service' do
|
|
484
|
-
expect(ExternalService).to receive(:notify_order_placed).with(order)
|
|
485
|
-
described_class.new.handle(event)
|
|
486
|
-
end
|
|
487
|
-
end
|
|
488
|
-
end
|
|
132
|
+
order.publish_event('order.flagged_for_review')
|
|
489
133
|
```
|
|
490
134
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
Stub `Spree::Events.publish` to assert an event is published:
|
|
494
|
-
|
|
495
|
-
```ruby
|
|
496
|
-
it 'publishes order.completed event' do
|
|
497
|
-
expect(Spree::Events).to receive(:publish).with(
|
|
498
|
-
'order.completed',
|
|
499
|
-
hash_including('id' => order.id)
|
|
500
|
-
)
|
|
501
|
-
|
|
502
|
-
order.complete!
|
|
503
|
-
end
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
The `lifecycle events` shared examples in `spree/core/lib/spree/testing_support/lifecycle_events.rb` cover the standard `created`/`updated`/`deleted` lifecycle events.
|
|
507
|
-
|
|
508
|
-
## Best Practices
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
- **Keep handlers fast** — Move slow operations to background jobs. Subscribers should do minimal work and delegate heavy lifting.
|
|
512
|
-
|
|
513
|
-
- **Handle missing records** — Always check if the record exists before processing. It may have been deleted between event publish and handler execution.
|
|
514
|
-
|
|
515
|
-
- **Be idempotent** — Design handlers to be safely re-run. Events might be delivered more than once in edge cases.
|
|
516
|
-
|
|
517
|
-
- **Use specific patterns** — Subscribe to specific events rather than wildcards when possible. This makes code easier to understand and debug.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
## Example: Inventory Alert Subscriber
|
|
521
|
-
|
|
522
|
-
Here's a complete example of a subscriber that sends alerts when inventory is low:
|
|
523
|
-
|
|
524
|
-
```ruby app/subscribers/inventory_alert_subscriber.rb
|
|
525
|
-
class InventoryAlertSubscriber < Spree::Subscriber
|
|
526
|
-
subscribes_to 'stock_item.updated'
|
|
527
|
-
|
|
528
|
-
LOW_STOCK_THRESHOLD = 10
|
|
529
|
-
|
|
530
|
-
def handle(event)
|
|
531
|
-
stock_item = find_stock_item(event)
|
|
532
|
-
return unless stock_item
|
|
533
|
-
return unless low_stock?(event)
|
|
534
|
-
|
|
535
|
-
send_low_stock_alert(stock_item)
|
|
536
|
-
end
|
|
537
|
-
|
|
538
|
-
private
|
|
539
|
-
|
|
540
|
-
def find_stock_item(event)
|
|
541
|
-
Spree::StockItem.find_by_prefix_id(event.payload['id'])
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def low_stock?(event)
|
|
545
|
-
event.payload['count_on_hand'].to_i < LOW_STOCK_THRESHOLD
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
def send_low_stock_alert(stock_item)
|
|
549
|
-
InventoryMailer.low_stock_alert(
|
|
550
|
-
variant: stock_item.variant,
|
|
551
|
-
stock_location: stock_item.stock_location,
|
|
552
|
-
count_on_hand: stock_item.count_on_hand
|
|
553
|
-
).deliver_later
|
|
554
|
-
end
|
|
555
|
-
end
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
> **NOTE:** A lifecycle event payload carries only the resource's current serialized state, not its previous values. If you need a true "just dropped below the threshold" check that compares the count before and after the change, record or cache the prior count separately rather than reading it from the event payload.
|
|
559
|
-
|
|
560
|
-
## Custom Event Adapters
|
|
561
|
-
|
|
562
|
-
Spree's event system uses an adapter pattern, making it possible to swap the underlying event infrastructure. By default, Spree uses [`ActiveSupport::Notifications`](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html), but you can create custom adapters for other backends like Kafka, RabbitMQ, or Redis Pub/Sub.
|
|
563
|
-
|
|
564
|
-
### Configuring a Custom Adapter
|
|
565
|
-
|
|
566
|
-
Set your adapter class in an initializer:
|
|
567
|
-
|
|
568
|
-
```ruby config/initializers/spree.rb
|
|
569
|
-
Spree.events_adapter_class = 'MyApp::Events::KafkaAdapter'
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
### Creating a Custom Adapter
|
|
573
|
-
|
|
574
|
-
Inherit from `Spree::Events::Adapters::Base` and implement the required methods:
|
|
575
|
-
|
|
576
|
-
```ruby app/models/my_app/events/kafka_adapter.rb
|
|
577
|
-
module MyApp
|
|
578
|
-
module Events
|
|
579
|
-
class KafkaAdapter < Spree::Events::Adapters::Base
|
|
580
|
-
def publish(event_name, payload, metadata = {})
|
|
581
|
-
event = build_event(event_name, payload, metadata)
|
|
582
|
-
|
|
583
|
-
# Publish to Kafka
|
|
584
|
-
kafka_producer.produce(
|
|
585
|
-
event.to_json,
|
|
586
|
-
topic: "spree.#{event_name}"
|
|
587
|
-
)
|
|
588
|
-
|
|
589
|
-
event
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
def subscribe(pattern, subscriber, options = {})
|
|
593
|
-
registry.register(pattern, subscriber, options)
|
|
594
|
-
end
|
|
595
|
-
|
|
596
|
-
def unsubscribe(pattern, subscriber)
|
|
597
|
-
registry.unregister(pattern, subscriber)
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
def activate!
|
|
601
|
-
@kafka_producer = Kafka.new(
|
|
602
|
-
seed_brokers: ENV['KAFKA_BROKERS']
|
|
603
|
-
).producer
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def deactivate!
|
|
607
|
-
@kafka_producer&.shutdown
|
|
608
|
-
end
|
|
609
|
-
|
|
610
|
-
private
|
|
611
|
-
|
|
612
|
-
attr_reader :kafka_producer
|
|
613
|
-
end
|
|
614
|
-
end
|
|
615
|
-
end
|
|
616
|
-
```
|
|
135
|
+
Useful when your own domain has moments worth reacting to — a fraud check completing, an approval granted.
|
|
617
136
|
|
|
618
|
-
|
|
137
|
+
## Guidance
|
|
619
138
|
|
|
620
|
-
|
|
139
|
+
**Listen for the specific event.** `order.placed` rather than `order.updated` with a status check.
|
|
621
140
|
|
|
622
|
-
|
|
623
|
-
|--------|-------------|
|
|
624
|
-
| `publish(event_name, payload, metadata)` | Publish an event, return `Spree::Event` |
|
|
625
|
-
| `subscribe(pattern, subscriber, options)` | Register a subscriber for a pattern |
|
|
626
|
-
| `unsubscribe(pattern, subscriber)` | Remove a subscriber |
|
|
627
|
-
| `activate!` | Called during application initialization |
|
|
628
|
-
| `deactivate!` | Called during shutdown |
|
|
141
|
+
**Assume events can arrive more than once.** A retry after a network blip can redeliver. Make handlers safe to run twice — check whether you've already acted before acting.
|
|
629
142
|
|
|
630
|
-
|
|
631
|
-
- `build_event(name, payload, metadata)` - Creates a `Spree::Event` instance
|
|
632
|
-
- `invoke_subscribers(event)` - Finds and invokes matching subscribers (internally calling `registry.subscriptions_for`)
|
|
633
|
-
- `registry` - Access to the `Spree::Events::Registry` instance
|
|
143
|
+
**Don't chain long sequences of subscribers.** When one event triggers a subscriber that triggers another, working out what happened after the fact becomes archaeology. Prefer one handler that does the sequence.
|
|
634
144
|
|
|
635
|
-
|
|
145
|
+
**Keep failures contained.** A subscriber that raises shouldn't take down anything else. Handle your own errors and log them.
|
|
636
146
|
|
|
637
|
-
## Related
|
|
147
|
+
## Related
|
|
638
148
|
|
|
639
|
-
- [
|
|
640
|
-
- [
|
|
641
|
-
- [
|
|
642
|
-
- [Customization Quickstart](../customization/quickstart.md) - Overview of all customization options
|
|
643
|
-
- [Decorators](../customization/decorators.md) - When to use decorators vs events
|
|
644
|
-
- [Checkout Flow](carts.md) - Using events in checkout customization
|
|
149
|
+
- [Webhooks](webhooks.md) — reacting from outside Spree
|
|
150
|
+
- [Orders](orders.md) — the order lifecycle these events describe
|
|
151
|
+
- [Imports & Exports](imports-exports.md) — completion events for bulk work
|