@spree/docs 0.1.176 → 0.1.178
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 +48 -48
- package/dist/developer/cli/quickstart.md +1 -1
- package/dist/developer/contributing/creating-an-extension.md +2 -2
- package/dist/developer/core-concepts/addresses.md +3 -3
- package/dist/developer/core-concepts/architecture.md +4 -4
- package/dist/developer/core-concepts/calculators.md +2 -2
- package/dist/developer/core-concepts/carts.md +207 -0
- package/dist/developer/core-concepts/channels.md +3 -3
- package/dist/developer/core-concepts/customers.md +1 -1
- package/dist/developer/core-concepts/events.md +1 -1
- package/dist/developer/core-concepts/fulfillments.md +278 -0
- package/dist/developer/core-concepts/imports-exports.md +6 -18
- package/dist/developer/core-concepts/inventory.md +3 -3
- package/dist/developer/core-concepts/media.md +1 -1
- package/dist/developer/core-concepts/metafields.md +1 -1
- package/dist/developer/core-concepts/orders.md +92 -339
- package/dist/developer/core-concepts/payments.md +1 -1
- package/dist/developer/core-concepts/promotions.md +68 -195
- package/dist/developer/core-concepts/returns-exchanges-claims.md +173 -0
- package/dist/developer/core-concepts/store-credits-gift-cards.md +16 -18
- package/dist/developer/core-concepts/stores.md +1 -1
- package/dist/developer/core-concepts/taxes-discounts-fees.md +199 -0
- package/dist/developer/core-concepts/taxes.md +3 -3
- package/dist/developer/create-spree-app/quickstart.md +1 -1
- package/dist/developer/customization/api.md +36 -11
- package/dist/developer/customization/configuration.md +207 -37
- package/dist/developer/customization/decorators.md +8 -8
- package/dist/developer/customization/permissions.md +34 -229
- package/dist/developer/customization/quickstart.md +159 -108
- package/dist/developer/customization/validations.md +1 -1
- package/dist/developer/dashboard/overview.md +1 -1
- package/dist/developer/how-to/build-a-b2b-store.md +21 -0
- package/dist/developer/how-to/build-a-marketplace.md +25 -0
- package/dist/developer/how-to/custom-api-authentication.md +1 -1
- package/dist/developer/how-to/custom-delivery-rate-provider.md +242 -0
- package/dist/developer/how-to/custom-document-numbers.md +154 -0
- package/dist/developer/how-to/custom-order-routing.md +1 -1
- package/dist/developer/how-to/custom-payment-method.md +1 -1
- package/dist/developer/how-to/custom-promotion.md +76 -115
- package/dist/developer/how-to/custom-report.md +2 -2
- package/dist/developer/how-to/custom-stock-splitter.md +5 -5
- package/dist/developer/how-to/sell-digital-products.md +20 -0
- package/dist/developer/multi-tenant/quickstart.md +1 -1
- package/dist/developer/providers/dam.md +14 -0
- package/dist/developer/providers/erp.md +31 -0
- package/dist/developer/providers/fulfillment.md +25 -0
- package/dist/developer/{deployment/telemetry.md → providers/observability.md} +1 -1
- package/dist/developer/providers/overview.md +44 -0
- package/dist/developer/providers/pim.md +25 -0
- package/dist/developer/providers/sso.md +20 -0
- package/dist/developer/sdk/admin/extending.md +1 -1
- package/dist/developer/sdk/admin/quickstart.md +1 -1
- package/dist/developer/sdk/admin/resources.md +1 -1
- package/dist/developer/sdk/extending.md +1 -1
- package/dist/developer/tutorial/admin-api.md +13 -0
- package/dist/developer/tutorial/dashboard-plugin.md +14 -0
- package/dist/developer/tutorial/events.md +7 -161
- package/dist/developer/tutorial/introduction.md +15 -35
- package/dist/developer/tutorial/model.md +7 -98
- package/dist/developer/tutorial/store-api.md +13 -0
- package/dist/developer/tutorial/storefront.md +12 -0
- package/dist/developer/tutorial/testing.md +7 -711
- package/dist/developer/upgrades/5.6-to-6.0.md +2 -2
- package/package.json +1 -1
- package/dist/developer/admin/admin.md +0 -214
- package/dist/developer/admin/authentication.md +0 -59
- package/dist/developer/admin/components.md +0 -711
- package/dist/developer/admin/custom-css.md +0 -256
- package/dist/developer/admin/custom-javascript.md +0 -116
- package/dist/developer/admin/extending-ui.md +0 -1839
- package/dist/developer/admin/form-builder.md +0 -444
- package/dist/developer/admin/helper-methods.md +0 -531
- package/dist/developer/admin/navigation.md +0 -805
- package/dist/developer/admin/tables.md +0 -490
- package/dist/developer/advanced/adding_spree_to_rails_app.md +0 -92
- package/dist/developer/core-concepts/adjustments.md +0 -113
- package/dist/developer/core-concepts/reports.md +0 -208
- package/dist/developer/core-concepts/shipments.md +0 -307
- package/dist/developer/core-concepts/users.md +0 -303
- package/dist/developer/customization/authentication.md +0 -100
- package/dist/developer/customization/checkout.md +0 -202
- package/dist/developer/customization/emails.md +0 -18
- package/dist/developer/customization/routes.md +0 -24
- package/dist/developer/multi-vendor/installation.md +0 -61
- package/dist/developer/multi-vendor/quickstart.md +0 -17
- package/dist/developer/tutorial/admin.md +0 -206
- package/dist/developer/tutorial/api.md +0 -606
- package/dist/developer/tutorial/extending-models.md +0 -393
- package/dist/developer/tutorial/sdk.md +0 -170
- package/dist/developer/upgrades/2.0-to-2.1.md +0 -46
- package/dist/developer/upgrades/2.1-to-2.2.md +0 -59
- package/dist/developer/upgrades/2.2-to-2.3.md +0 -44
- package/dist/developer/upgrades/2.3-to-2.4.md +0 -42
- package/dist/developer/upgrades/3.0-to-3.1.md +0 -47
- package/dist/developer/upgrades/3.1-to-3.2.md +0 -34
- package/dist/developer/upgrades/3.2-to-3.3.md +0 -70
- package/dist/developer/upgrades/3.3-to-3.4.md +0 -36
- package/dist/developer/upgrades/3.4-to-3.5.md +0 -44
- package/dist/developer/upgrades/3.5-to-3.6.md +0 -40
- package/dist/developer/upgrades/3.6-to-3.7.md +0 -62
- package/dist/developer/upgrades/3.7-to-4.0.md +0 -152
- package/dist/developer/upgrades/4.0-to-4.1.md +0 -92
- package/dist/developer/upgrades/4.1-to-4.2.md +0 -109
- package/dist/developer/upgrades/4.10-to-5.0.md +0 -131
- package/dist/developer/upgrades/4.2-to-4.3.md +0 -100
- package/dist/developer/upgrades/4.3-to-4.4.md +0 -125
- package/dist/developer/upgrades/4.4-to-4.5.md +0 -94
- package/dist/developer/upgrades/4.5-to-4.6.md +0 -119
- package/dist/developer/upgrades/4.6-to-4.7.md +0 -39
- package/dist/developer/upgrades/4.8-to-4.9.md +0 -24
- package/dist/developer/upgrades/4.9-to-4.10.md +0 -24
- package/dist/developer/upgrades/4.x-to-4.8.md +0 -52
- package/dist/developer/upgrades/5.0-to-5.1.md +0 -28
- package/dist/developer/upgrades/5.1-to-5.2.md +0 -131
- package/dist/developer/upgrades/5.2-to-5.3.md +0 -338
- package/dist/developer/upgrades/5.3-to-5.4.md +0 -277
- package/dist/developer/upgrades/5.4-to-5.5.md +0 -301
- package/dist/developer/upgrades/5.5-to-5.6.md +0 -207
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a Custom Delivery Rate Provider
|
|
3
|
+
description: Connect a carrier or rate aggregator to Spree so checkout quotes live delivery rates instead of calculator-based prices.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
A delivery rate provider decides **where a delivery method's price comes from**. Spree ships with one built-in provider, `Spree::DeliveryRateProvider::Internal`, which prices through the method's calculator — flat rates, per-item rates, and the rest. That is the default, and stores that never install a carrier integration keep using it.
|
|
9
|
+
|
|
10
|
+
A custom provider replaces that arithmetic with a live quote: you call a carrier or an aggregator, and the rate that reaches checkout carries the carrier name, the service level, and an estimated delivery date.
|
|
11
|
+
|
|
12
|
+
Use a provider when the price comes from outside Spree. If you only need different arithmetic on values Spree already has, write a calculator instead — it is far less work. See [Fulfillments](../core-concepts/fulfillments.md) for how delivery methods, rates and fulfillments fit together.
|
|
13
|
+
|
|
14
|
+
## What you will build
|
|
15
|
+
|
|
16
|
+
Three pieces, each with one job:
|
|
17
|
+
|
|
18
|
+
| Piece | Responsibility |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `Spree::Integration` subclass | Holds the store's credentials, managed from the admin dashboard |
|
|
21
|
+
| `Spree::DeliveryRateProvider::Base` subclass | Turns a package into a rate quote |
|
|
22
|
+
| An initializer | Registers both so admins can select them |
|
|
23
|
+
|
|
24
|
+
Providers are stateless strategy objects. They never store credentials themselves — that is the integration's job, which is what gives merchants one place to see everything connected to their store.
|
|
25
|
+
|
|
26
|
+
## 1. Store credentials in an integration
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
module SpreeAcmeCarrier
|
|
30
|
+
class Integration < Spree::Integration
|
|
31
|
+
preference :api_key, :password
|
|
32
|
+
preference :account_number, :string
|
|
33
|
+
preference :test_mode, :boolean, default: true
|
|
34
|
+
|
|
35
|
+
def self.integration_group = 'shipping'
|
|
36
|
+
|
|
37
|
+
# Called when an admin activates the integration. Returning false blocks
|
|
38
|
+
# activation and shows your message in the dashboard.
|
|
39
|
+
def can_connect?
|
|
40
|
+
client.ping
|
|
41
|
+
true
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
self.connection_error_message = e.message
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def client
|
|
48
|
+
@client ||= AcmeCarrier::Client.new(api_key: preferred_api_key, test: preferred_test_mode)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Declare secrets as `:password` preferences. Spree masks them on read and guards the round-trip on write, so an API key never leaves the server in plain text.
|
|
55
|
+
|
|
56
|
+
## 2. Implement the provider
|
|
57
|
+
|
|
58
|
+
Implement `#estimates` — it receives a `Spree::Stock::Package` and returns an array of `Estimate` objects, one per carrier service you can quote. Every estimate becomes its own named option at checkout ("Acme Ground", "Acme Express"), and the merchant narrows, renames, or marks up individual services from the delivery method's carrier-services card. An empty array hides the method.
|
|
59
|
+
|
|
60
|
+
(Single-quote providers can implement `#estimate` returning one `Estimate` or `nil` instead — the base class wraps it.)
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
module SpreeAcmeCarrier
|
|
64
|
+
class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
|
|
65
|
+
def self.integration_class = 'SpreeAcmeCarrier::Integration'
|
|
66
|
+
|
|
67
|
+
def estimates(package)
|
|
68
|
+
quotes_for(package).map do |quote|
|
|
69
|
+
Spree::DeliveryRateProvider::Estimate.new(
|
|
70
|
+
cost: quote.amount,
|
|
71
|
+
# ISO code of the currency the carrier quoted in. Estimates in a
|
|
72
|
+
# different currency than the cart's are dropped — never convert
|
|
73
|
+
# silently; nil means the store's own currency.
|
|
74
|
+
currency: quote.currency,
|
|
75
|
+
carrier: quote.carrier_name,
|
|
76
|
+
service_level: quote.service_name,
|
|
77
|
+
estimated_delivery_date: quote.delivery_date,
|
|
78
|
+
metadata: { quote_id: quote.id }
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
rescue AcmeCarrier::Error => e
|
|
82
|
+
# Never raise into checkout: report and hide this method instead.
|
|
83
|
+
Rails.error.report(e, context: { delivery_method_id: delivery_method.id }, source: 'acme.rating')
|
|
84
|
+
[]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# One API call serves every delivery method sharing this provider within
|
|
90
|
+
# a request — carriers return all services in one response, so quoting
|
|
91
|
+
# five methods should not mean five round-trips.
|
|
92
|
+
def quotes_for(package)
|
|
93
|
+
key = [:acme_quotes, store.id, package.stock_location.id, package.order.id]
|
|
94
|
+
Spree::Current.provider_cache[key] ||= integration.client.rates(
|
|
95
|
+
from: package.stock_location,
|
|
96
|
+
to: package.order.ship_address,
|
|
97
|
+
parcel: { weight: package.weight }
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Never let `estimates` raise.** It runs inside checkout's rate refresh, so an exception breaks the whole delivery step for the customer. Rescue your carrier's errors, report them (`Rails.error.report`), and return `[]` — the method disappears from the options while everything else keeps working.
|
|
105
|
+
|
|
106
|
+
Optionally implement `def self.service_catalog(integration)` so the admin service picker can list the carrier's services as checkboxes. Fetch them live — a hardcoded list offers services the merchant has not enabled and hides the ones they have. Return one of three shapes, and never raise:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def self.service_catalog(integration)
|
|
110
|
+
return Spree::DeliveryRateProvider::ServiceCatalog.none if integration.nil?
|
|
111
|
+
|
|
112
|
+
services = integration.client.services.map do |service|
|
|
113
|
+
{ carrier: service.carrier, service: service.code, label: service.name }
|
|
114
|
+
end
|
|
115
|
+
Spree::DeliveryRateProvider::ServiceCatalog.listing(services)
|
|
116
|
+
rescue AcmeCarrier::Error => e
|
|
117
|
+
# The merchant sees the carrier's own words — "no services" and "we
|
|
118
|
+
# could not ask" are different problems.
|
|
119
|
+
Spree::DeliveryRateProvider::ServiceCatalog.unavailable(e.message)
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`ServiceCatalog.none` is the default: the provider lists nothing and the merchant types identifiers free-form, which also stays available when a listing fails. Carrier and service values must match what your rates carry — the picker's rows are matched against quoted rates by exactly those two fields.
|
|
124
|
+
|
|
125
|
+
**Returning `nil` hides the delivery method** for that package. This is the same contract calculators follow, and it is how you express "this carrier does not serve this destination" — the method simply does not appear at checkout rather than appearing at a wrong price.
|
|
126
|
+
|
|
127
|
+
`cost` is pre-tax and pre-VAT. Spree applies the gross-up and resolves the tax rate afterwards, exactly as it does for calculator output, so you never handle tax yourself.
|
|
128
|
+
|
|
129
|
+
Declaring `integration_class` is all the availability wiring you need: Spree derives `available_for_store?` from it, so your provider is hidden from the admin picker — and rejected on save — until the merchant connects the integration.
|
|
130
|
+
|
|
131
|
+
### Optional lifecycle hooks
|
|
132
|
+
|
|
133
|
+
`book` and `release` are part of the provider contract but Spree does not invoke them yet — they are reserved for the rate booking flow. Until then, call `book` yourself from your gem's fulfillment provider when the label is purchased:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# Reserve the quote once your fulfillment provider dispatches.
|
|
137
|
+
def book(delivery_rate)
|
|
138
|
+
integration.client.book(delivery_rate.metadata['quote_id'])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Release a quote that is no longer wanted.
|
|
142
|
+
def release(delivery_rate)
|
|
143
|
+
integration.client.release(delivery_rate.metadata['quote_id'])
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 3. Register both classes
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# config/initializers/spree.rb
|
|
151
|
+
Spree.delivery_rate_providers << 'SpreeAcmeCarrier::DeliveryRateProvider'.constantize
|
|
152
|
+
Spree.integrations << 'SpreeAcmeCarrier::Integration'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
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
|
+
|
|
157
|
+
## 4. Use it
|
|
158
|
+
|
|
159
|
+
A merchant connects the integration under **Settings → Integrations**, then picks the provider on a delivery method under **Settings → Delivery methods**. The provider field only appears once more than one provider is available, so stores without a carrier integration never see it.
|
|
160
|
+
|
|
161
|
+
One delivery method is the carrier connection: every service your provider returns becomes its own option at checkout, so a single "Acme shipping" method is usually all a merchant creates. The method's carrier-services card narrows which services are offered, renames them ("Acme 1 day"), and adds per-service or method-wide markup.
|
|
162
|
+
|
|
163
|
+
Providers and calculators coexist freely. A store can price "Free shipping" with a flat-rate calculator, "Express" through your carrier, and "Local pickup" with neither.
|
|
164
|
+
|
|
165
|
+
## Feeding carrier tracking back in
|
|
166
|
+
|
|
167
|
+
Once a parcel is moving, most carriers will tell you where it is. Spree keeps
|
|
168
|
+
that on a separate field from the fulfillment's own status, so a bounced parcel
|
|
169
|
+
never un-ships itself — see [Fulfillments](../core-concepts/fulfillments.md#statuses).
|
|
170
|
+
|
|
171
|
+
Spree owns the endpoint: every integration gets one at
|
|
172
|
+
`POST /api/v3/webhooks/fulfillments/:integration_id` — the merchant pastes
|
|
173
|
+
that URL into the carrier's webhook settings. Your job is one method on the
|
|
174
|
+
integration:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class SpreeAcmeCarrier::Integration < Spree::Integration
|
|
178
|
+
preference :api_key, :password
|
|
179
|
+
preference :webhook_secret, :password
|
|
180
|
+
|
|
181
|
+
# Verify the signature against your secret, translate the payload, return
|
|
182
|
+
# UpdateTracking arguments plus the tracking code to match on — or nil for
|
|
183
|
+
# events you don't act on.
|
|
184
|
+
def parse_webhook_event(raw_post, headers)
|
|
185
|
+
raise Spree::Integration::WebhookSignatureError if preferred_webhook_secret.blank?
|
|
186
|
+
|
|
187
|
+
payload = AcmeCarrier.verify!(raw_post, headers, preferred_webhook_secret) # raises on a bad signature
|
|
188
|
+
|
|
189
|
+
return unless payload['type'] == 'shipment.status_changed'
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
tracking_code: payload['tracking_code'],
|
|
193
|
+
tracking_status: STATUS_MAP.fetch(payload['status'], 'unknown'), # Spree::Fulfillment::TRACKING_STATUSES
|
|
194
|
+
estimated_delivery_at: payload['eta'],
|
|
195
|
+
delivered_at: payload['delivered_scan_at'],
|
|
196
|
+
details: payload.slice('status_detail', 'carrier')
|
|
197
|
+
}
|
|
198
|
+
rescue AcmeCarrier::SignatureError => e
|
|
199
|
+
raise Spree::Integration::WebhookSignatureError, e.message
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The endpoint does the rest: signature failures answer `401` so a misconfigured
|
|
205
|
+
sender notices, unmatched tracking codes and `nil` events are acknowledged with
|
|
206
|
+
`200` so the carrier never retries a payload that cannot succeed, and matched
|
|
207
|
+
events run through `Fulfillments::UpdateTracking` — reporting `delivered` also
|
|
208
|
+
confirms receipt on the fulfillment, and an update carrying only a scan leaves
|
|
209
|
+
an earlier estimate alone.
|
|
210
|
+
|
|
211
|
+
Three rules worth keeping:
|
|
212
|
+
|
|
213
|
+
- **Verify or refuse.** Raise `WebhookSignatureError` when no secret is
|
|
214
|
+
configured rather than accepting unsigned reports — a forged `delivered`
|
|
215
|
+
starts the customer's return window and, in the EU, the withdrawal clock.
|
|
216
|
+
- **Pass `delivered_at` from the carrier's delivering scan**, not the webhook's
|
|
217
|
+
arrival time. The webhook usually lands well after the parcel does.
|
|
218
|
+
- **Translate in your gem, never in core.** Map unrecognised carrier statuses
|
|
219
|
+
to `unknown` so the report stays visible instead of being dropped.
|
|
220
|
+
|
|
221
|
+
## Testing
|
|
222
|
+
|
|
223
|
+
Your provider is a plain object, so test it directly:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
RSpec.describe SpreeAcmeCarrier::DeliveryRateProvider do
|
|
227
|
+
let(:delivery_method) { create(:delivery_method, rate_provider: described_class.to_s) }
|
|
228
|
+
|
|
229
|
+
it 'quotes every service the carrier returns' do
|
|
230
|
+
estimates = described_class.new(delivery_method).estimates(package)
|
|
231
|
+
|
|
232
|
+
expect(estimates.map(&:service_level)).to contain_exactly('Ground', 'Express')
|
|
233
|
+
expect(estimates.first.carrier).to eq('Acme')
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'hides the method when the carrier does not serve the destination' do
|
|
237
|
+
expect(described_class.new(delivery_method).estimates(unservable_package)).to eq([])
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Stub the carrier client rather than calling the real API — quoting runs on every checkout, so a slow or flaky test here is a slow or flaky suite everywhere.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Customize Document Numbers
|
|
3
|
+
description: Change the shape of order, return and other document numbers — from the dashboard for order numbers, or in code for full control.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
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
|
+
|
|
10
|
+
Numbers are separate from IDs. Every record also has a prefixed ID (`order_86Rf07xd4z`) which is what the API uses and what you should reference in code. The number exists purely so people can read, say and type it.
|
|
11
|
+
|
|
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
|
+
|
|
14
|
+
## Merchant settings
|
|
15
|
+
|
|
16
|
+
**Settings → Store → Order numbers** controls four things:
|
|
17
|
+
|
|
18
|
+
| Setting | Effect |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Numbering format | Sequential (default) or random. Applies to every numbered document. |
|
|
21
|
+
| Prefix | Leads each order number. Defaults to `R`. |
|
|
22
|
+
| Suffix | Ends each order number. Empty by default. |
|
|
23
|
+
| Start numbering at | The first order number. Defaults to `1001`. |
|
|
24
|
+
|
|
25
|
+
Sequential numbering counts up: `R1001`, `R1002`, `R1003`. Random numbering produces nine unpredictable digits, which is what Spree did before 6.0. The trade-off is worth stating to merchants: sequential numbers are far easier to read back over the phone, but a customer who orders twice can see how many orders you took in between. Random numbers give that away nothing, at the cost of legibility.
|
|
26
|
+
|
|
27
|
+
Two rules apply to every change:
|
|
28
|
+
|
|
29
|
+
- **Changes affect future numbers only.** Numbers already issued are permanent — they are printed on invoices, quoted in emails, and stored at payment gateways.
|
|
30
|
+
- **The starting value only applies before the first order.** Set it to `10001` on a new store and the first order is `R10001`, the second `R10002`. Once the counter has issued a number the field is locked in the dashboard, because raising it then would do nothing — the counter keeps going from where it is.
|
|
31
|
+
|
|
32
|
+
Prefix and suffix have no such restriction. Change them whenever you like: `INV` with suffix `-EU` and a start of `5001` gives `INV5001-EU`, and existing orders keep the numbers they were issued.
|
|
33
|
+
|
|
34
|
+
> **NOTE:** Sequential numbering is *mostly* gapless, not guaranteed gapless — an abandoned checkout or a rare collision consumes a value. Do not rely on it for legal invoice numbering, which in most jurisdictions requires a provably unbroken sequence.
|
|
35
|
+
|
|
36
|
+
## Custom generators
|
|
37
|
+
|
|
38
|
+
When the settings are not enough — you need the year in the number, a per-warehouse prefix, or a format your ERP already expects — register a generator class.
|
|
39
|
+
|
|
40
|
+
A generator answers one question: what should this record's number be? Uniqueness is handled for you, so a generator only has to propose.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# lib/my_app/branch_order_numbers.rb
|
|
44
|
+
module MyApp
|
|
45
|
+
class BranchOrderNumbers < Spree::NumberGenerators::Base
|
|
46
|
+
# @param record [Spree::Order]
|
|
47
|
+
# @return [String]
|
|
48
|
+
def generate(record)
|
|
49
|
+
branch = record.number_store.code.upcase
|
|
50
|
+
year = Time.current.year
|
|
51
|
+
|
|
52
|
+
"#{branch}-#{year}-#{SecureRandom.random_number(10_000).to_s.rjust(4, '0')}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Register it in an initializer:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# config/initializers/spree.rb
|
|
62
|
+
Spree.number_generators[:order] = 'MyApp::BranchOrderNumbers'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That is the whole contract. Orders now get numbers like `NYC-2026-0042`; every other document type keeps following the store's settings.
|
|
66
|
+
|
|
67
|
+
The resource key matches the model name, underscored: `:order`, `:return`, `:exchange`, `:claim`, `:stock_transfer`, `:import`, `:export`. Register the same class under several keys to share one format across document types.
|
|
68
|
+
|
|
69
|
+
A registered generator wins over the store's format setting — that is the point of registering one. If you want merchants to keep some control, read their settings yourself through `prefix_for` and `suffix_for` (below).
|
|
70
|
+
|
|
71
|
+
`Spree.number_generators.delete(:order)` removes the registration and hands numbering back to the store settings.
|
|
72
|
+
|
|
73
|
+
### What a generator can read
|
|
74
|
+
|
|
75
|
+
The record is passed to `generate`, so anything reachable from it is available:
|
|
76
|
+
|
|
77
|
+
- `record.number_store` — the store whose settings apply, wherever it lives on the model
|
|
78
|
+
- `record.class.number_prefix` — the model's built-in prefix (`R`, `RET`, …)
|
|
79
|
+
- Any attribute of the record itself
|
|
80
|
+
|
|
81
|
+
Inheriting from `Spree::NumberGenerators::Base` also gives you `prefix_for(record)` and `suffix_for(record)`, which return the merchant's configured values for orders and the code-level prefix for everything else. Use them when you want to extend the merchant's choice rather than override it.
|
|
82
|
+
|
|
83
|
+
### Sequential counters
|
|
84
|
+
|
|
85
|
+
If your generator needs its own counter, use the same one the built-in sequential generator uses rather than deriving a maximum from existing rows. This generator combines both — the merchant's prefix, a year, and a zero-padded counter:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
module MyApp
|
|
89
|
+
class YearScopedNumbers < Spree::NumberGenerators::Base
|
|
90
|
+
def generate(record)
|
|
91
|
+
sequence = Spree::NumberSequence.next_value(
|
|
92
|
+
store: record.number_store,
|
|
93
|
+
resource_type: 'order',
|
|
94
|
+
start_at: 1
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
"#{prefix_for(record)}-#{Time.current.year}-#{sequence.to_s.rjust(5, '0')}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
With the default prefix that produces `R-2026-00001`; a merchant who changes their prefix to `ACME` gets `ACME-2026-00001` without you touching the class.
|
|
104
|
+
|
|
105
|
+
Deriving the next value by parsing existing numbers looks simpler but breaks in three ways: legacy numbers from before your format existed do not parse, string columns sort `R999` above `R1000`, and two concurrent checkouts read the same maximum and produce the same number. The counter is locked for the increment, so it hands out distinct values under load.
|
|
106
|
+
|
|
107
|
+
## Adding numbers to your own model
|
|
108
|
+
|
|
109
|
+
Models you add can carry document numbers too:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class MyApp::Consignment < Spree.base_class
|
|
113
|
+
has_spree_number prefix: 'CN'
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The record is numbered before validation on create, and the number follows the store's format setting like everything else. The model needs a `number` column with a unique index:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
add_column :my_app_consignments, :number, :string, null: false
|
|
121
|
+
add_index :my_app_consignments, :number, unique: true
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If the model does not respond to `store`, define `number_store` so the generator knows whose settings to read:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
def number_store
|
|
128
|
+
warehouse.store
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Fulfillments and payments
|
|
133
|
+
|
|
134
|
+
Fulfillments and payments do **not** have their own numbers. Theirs are derived from the order they belong to — `R1001-F1` for the first parcel, `R1001-P1` for the first payment — so they group visibly with their order and follow whatever format the merchant chose.
|
|
135
|
+
|
|
136
|
+
This is not configurable and there is no generator to register. If you need a different shape, override `number` on the model.
|
|
137
|
+
|
|
138
|
+
## Upgrading from Spree 5.x
|
|
139
|
+
|
|
140
|
+
Two changes matter.
|
|
141
|
+
|
|
142
|
+
**Numbers are sequential by default.** Stores upgrading from 5.x keep every existing number untouched, but new documents get sequential numbers starting at 1001. To keep the old behavior, set the store's numbering format to **Random** in Settings → Store → Order numbers.
|
|
143
|
+
|
|
144
|
+
**`Spree::Core::NumberGenerator` is deprecated.** Models using it still work through Spree 6.0 and log a deprecation warning. Replace it with the concern:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# before
|
|
148
|
+
include Spree::Core::NumberGenerator.new(prefix: 'R', length: 9)
|
|
149
|
+
|
|
150
|
+
# after
|
|
151
|
+
has_spree_number prefix: 'R'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The `length:` and `letters:` options are gone — they described the random format, which is now the generator's business rather than the model's. If you were relying on a specific length, register a custom generator.
|
|
@@ -15,7 +15,7 @@ Order routing decides which [Stock Location](../core-concepts/inventory.md#stock
|
|
|
15
15
|
|
|
16
16
|
This guide covers both. Most extensions are rules — they compose with the built-ins and don't require rewriting the pipeline.
|
|
17
17
|
|
|
18
|
-
Before starting, make sure you understand [how order routing works in Spree](../core-concepts/
|
|
18
|
+
Before starting, make sure you understand [how order routing works in Spree](../core-concepts/fulfillments.md#order-routing).
|
|
19
19
|
|
|
20
20
|
| If the answer is "yes" | Pick |
|
|
21
21
|
|---|---|
|
|
@@ -368,7 +368,7 @@ For every gateway action (authorize, purchase, capture, void, credit), Spree pas
|
|
|
368
368
|
## Related Documentation
|
|
369
369
|
|
|
370
370
|
- [Payments](../core-concepts/payments.md) - Payment architecture and core concepts
|
|
371
|
-
- [Checkout Customization](../
|
|
371
|
+
- [Checkout Customization](../core-concepts/carts.md) - Customizing the checkout flow
|
|
372
372
|
- [Events](../core-concepts/events.md) - Subscribe to payment events
|
|
373
373
|
- [Stripe Integration](../../integrations/payments/stripe.md) - Reference implementation using Stripe
|
|
374
374
|
- [Adyen Integration](../../integrations/payments/adyen.md) - Reference implementation using Adyen
|