@spree/docs 0.1.241 → 0.1.242
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.
|
@@ -155,7 +155,8 @@ otherwise every parcel is quoted light.
|
|
|
155
155
|
|
|
156
156
|
Package types are that vocabulary: the box parcels ship in, and for wholesale,
|
|
157
157
|
the cartons products are packed into and the pallets or containers an order
|
|
158
|
-
leaves on.
|
|
158
|
+
leaves on. How those cartons and pallets roll up into a quote is
|
|
159
|
+
[freight](freight.md).
|
|
159
160
|
|
|
160
161
|
|
|
161
162
|
```typescript Admin SDK
|
|
@@ -248,6 +249,7 @@ box of their own with all three dimensions and a weight recorded. See
|
|
|
248
249
|
|
|
249
250
|
## Related
|
|
250
251
|
|
|
252
|
+
- [Freight](freight.md) — cartons, pallets and wholesale shipping
|
|
251
253
|
- [Fulfillments](fulfillments.md) — what happens to the parcel afterwards
|
|
252
254
|
- [Calculators](calculators.md) — how a price is worked out
|
|
253
255
|
- [EasyPost](../../integrations/shipping/easypost.md) — live carrier rates and labels
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Freight
|
|
3
|
+
description: How wholesale orders ship — the carton and pallet chain a product declares, the logistics rollup a forwarder quotes against, and rates that carry no price yet.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
A wholesale order does not leave the warehouse as parcels. It leaves as
|
|
9
|
+
cartons, a pallet, or a container, and for international freight the price is
|
|
10
|
+
quoted by a forwarder after someone looks at the order — not by an API at
|
|
11
|
+
checkout.
|
|
12
|
+
|
|
13
|
+
Four pieces make that work, and none of them disturb retail shipping:
|
|
14
|
+
|
|
15
|
+
| Piece | Question it answers |
|
|
16
|
+
|---|---|
|
|
17
|
+
| **Carton details** on a product | How do these units pack? |
|
|
18
|
+
| **Freight summary** on a cart or order | How big is the whole load? |
|
|
19
|
+
| **Volume and company rules** on a delivery method | Which shipment tier is this? |
|
|
20
|
+
| **Unpriced rates** | What does a buyer see before a price exists? |
|
|
21
|
+
|
|
22
|
+
One catalog serves both audiences. The same product ships parcel to a shopper
|
|
23
|
+
and carton to a trade buyer, because freight methods sit on the ordinary
|
|
24
|
+
shipping profile and are gated by who is buying rather than by what is bought.
|
|
25
|
+
See [delivery setup](delivery-setup.md) for profiles,
|
|
26
|
+
zones and methods, and [package types](delivery-setup.md#packaging)
|
|
27
|
+
for the packaging vocabulary this builds on.
|
|
28
|
+
|
|
29
|
+
## How products pack
|
|
30
|
+
|
|
31
|
+
Freight numbers are not derivable from retail shipping data. A case of 48
|
|
32
|
+
bottles is one carton, not 48 parcels — so a product says how its units pack,
|
|
33
|
+
and the rest follows the chain **unit → carton → pallet → cubic meters and
|
|
34
|
+
weight**.
|
|
35
|
+
|
|
36
|
+
The geometry lives on a shared carton, because merchants reuse a handful of
|
|
37
|
+
standard sizes across hundreds of products and one edit should fix all of
|
|
38
|
+
them. What varies per product stays on the product:
|
|
39
|
+
|
|
40
|
+
| Field | What it says |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `carton_package_type_id` | Which carton this is packed into. Must be a package type of kind `carton` |
|
|
43
|
+
| `units_per_carton` | How many units one carton holds |
|
|
44
|
+
| `carton_weight` | What a packed carton weighs, gross. Optional |
|
|
45
|
+
| `cartons_per_pallet` | How those cartons stack |
|
|
46
|
+
| `purchase_unit` | `unit` or `carton` — what a buyer is quoted in |
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
```typescript Admin SDK
|
|
50
|
+
// The carton, once, shared by every product packed in it.
|
|
51
|
+
const carton = await adminClient.packageTypes.create({
|
|
52
|
+
name: 'Master carton',
|
|
53
|
+
kind: 'carton',
|
|
54
|
+
length: 40, width: 30, height: 25, dimensions_unit: 'cm',
|
|
55
|
+
weight: 0.4, max_weight: 20, weight_unit: 'kg',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// How this product fills it.
|
|
59
|
+
await adminClient.products.variants.update('prod_xxx', 'variant_xxx', {
|
|
60
|
+
carton_package_type_id: carton.id,
|
|
61
|
+
units_per_carton: 48,
|
|
62
|
+
carton_weight: 19.2,
|
|
63
|
+
cartons_per_pallet: 40,
|
|
64
|
+
purchase_unit: 'carton',
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash cURL
|
|
69
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/package_types' \
|
|
70
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
71
|
+
-H 'Content-Type: application/json' \
|
|
72
|
+
-d '{
|
|
73
|
+
"name": "Master carton", "kind": "carton",
|
|
74
|
+
"length": 40, "width": 30, "height": 25, "dimensions_unit": "cm",
|
|
75
|
+
"weight": 0.4, "max_weight": 20, "weight_unit": "kg"
|
|
76
|
+
}'
|
|
77
|
+
|
|
78
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx/variants/variant_xxx' \
|
|
79
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
80
|
+
-H 'Content-Type: application/json' \
|
|
81
|
+
-d '{
|
|
82
|
+
"carton_package_type_id": "pkgtype_xxx",
|
|
83
|
+
"units_per_carton": 48,
|
|
84
|
+
"carton_weight": 19.2,
|
|
85
|
+
"cartons_per_pallet": 40,
|
|
86
|
+
"purchase_unit": "carton"
|
|
87
|
+
}'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
Stored quantities are always units, at every level. `purchase_unit` only
|
|
92
|
+
changes the vocabulary a storefront presents — a buyer shown "2 cartons" is
|
|
93
|
+
buying 96 units.
|
|
94
|
+
|
|
95
|
+
Two data errors are refused while the merchant is still editing rather than
|
|
96
|
+
discovered through a customer complaint: an `order_multiple` that straddles
|
|
97
|
+
carton boundaries can never ship whole, and quoting in cartons without saying
|
|
98
|
+
how many units one holds leaves a storefront no way to render the offer.
|
|
99
|
+
|
|
100
|
+
Reading a product back adds `units_per_pallet`, derived from the two divisors
|
|
101
|
+
so a merchant sees the far end of the chain without doing the arithmetic. It
|
|
102
|
+
is null unless both halves are recorded.
|
|
103
|
+
|
|
104
|
+
> **NOTE:** A product may only be packed into a package type of kind `carton`, and a
|
|
105
|
+
> carton with products packed into it cannot later become a pallet. Repack the
|
|
106
|
+
> products first.
|
|
107
|
+
|
|
108
|
+
## The freight summary
|
|
109
|
+
|
|
110
|
+
The **freight summary** is the load as a forwarder reads it: how many units,
|
|
111
|
+
the cartons they fill, the pallets those stack onto, the cubic meters they
|
|
112
|
+
occupy and what the whole thing weighs.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"total_units": 480,
|
|
117
|
+
"total_cartons": 10,
|
|
118
|
+
"total_pallets": 1,
|
|
119
|
+
"total_volume": "0.3",
|
|
120
|
+
"total_weight": "192.0",
|
|
121
|
+
"complete": true
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`total_volume` is cubic meters and `total_weight` is kilograms, both as
|
|
126
|
+
decimal strings. `total_pallets` is null unless every carton-bearing line says
|
|
127
|
+
how it stacks — one silent product would understate the load, so no figure is
|
|
128
|
+
better than a wrong one.
|
|
129
|
+
|
|
130
|
+
**`complete` is the field to read before quoting from any of it.** False means
|
|
131
|
+
part of the catalog carries no carton data, so those products were measured
|
|
132
|
+
loose from their unit dimensions instead. The numbers are still the best
|
|
133
|
+
available and real enough to quote against, but they are a floor rather than a
|
|
134
|
+
total, and a surface using them should say so.
|
|
135
|
+
|
|
136
|
+
A cart of unmeasured goods has no freight summary at all rather than one full
|
|
137
|
+
of zeros, which would read as a shipment that takes up no space.
|
|
138
|
+
|
|
139
|
+
### Where it appears
|
|
140
|
+
|
|
141
|
+
| Response | Shape |
|
|
142
|
+
|---|---|
|
|
143
|
+
| Store cart | Totals only |
|
|
144
|
+
| Store delivery rate | Totals only, on freight rates |
|
|
145
|
+
| Admin cart and order | Totals plus a `lines` breakdown |
|
|
146
|
+
| Admin delivery rate | Totals plus `lines` |
|
|
147
|
+
|
|
148
|
+
The per-product breakdown is back-office only. A buyer is told what their
|
|
149
|
+
shipment costs and how big it is; which SKU accounts for which part of it is
|
|
150
|
+
the warehouse's business. Each admin line carries `variant_id`, `sku`, `name`,
|
|
151
|
+
`units`, `cartons`, `pallets`, `units_per_carton`, `cartons_per_pallet`,
|
|
152
|
+
`weight_per_carton`, `volume`, `weight` and its own `complete` flag.
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
```typescript SDK
|
|
156
|
+
const cart = await client.carts.get('cart_xxx')
|
|
157
|
+
|
|
158
|
+
if (cart.freight_summary) {
|
|
159
|
+
const { total_cartons, total_pallets, total_volume, complete } = cart.freight_summary
|
|
160
|
+
// `complete: false` — show these as a minimum, not a total.
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```bash cURL
|
|
165
|
+
curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
|
|
166
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
167
|
+
-H 'X-Spree-Token: guest_token'
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
> **NOTE:** A placed order's summary is never re-derived from the live catalog. The freight
|
|
172
|
+
> provider freezes a copy onto the rate it quoted, and the order reads that — so
|
|
173
|
+
> a carton size corrected next month does not rewrite what this container held.
|
|
174
|
+
> Two freight consignments on one order are merged into the one load the
|
|
175
|
+
> forwarder sees, and a canceled one is left out.
|
|
176
|
+
|
|
177
|
+
## Freight rates
|
|
178
|
+
|
|
179
|
+
A freight method is an ordinary [delivery method](delivery-setup.md#delivery-methods-and-zones)
|
|
180
|
+
priced by the **Freight** rate provider. That provider quotes nothing and says
|
|
181
|
+
so: it returns a rate with no price, carrying the freight summary the merchant
|
|
182
|
+
will send to the forwarder in place of an amount.
|
|
183
|
+
|
|
184
|
+
Such a rate comes back with `unpriced: true`, and every money field on it reads
|
|
185
|
+
**"Quoted after review"** rather than a figure. That matters: a zero cost
|
|
186
|
+
rendering as "Free" over a container of goods is a promise the merchant cannot
|
|
187
|
+
keep. Unpriced rates are also sorted after every priced one and are never
|
|
188
|
+
preselected, so a buyer with a real parcel option still sees it first.
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
```typescript SDK
|
|
192
|
+
const rates = fulfillment.delivery_rates
|
|
193
|
+
|
|
194
|
+
rates.forEach((rate) => {
|
|
195
|
+
if (rate.unpriced) {
|
|
196
|
+
rate.display_total // "Quoted after review"
|
|
197
|
+
rate.freight_summary // what the forwarder will quote against
|
|
198
|
+
} else {
|
|
199
|
+
rate.display_total // "$24.00"
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
```bash cURL
|
|
205
|
+
curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
|
|
206
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
207
|
+
-H 'X-Spree-Token: guest_token'
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
Checkout completes on an unpriced rate. The shipping price arrives afterwards,
|
|
212
|
+
once the forwarder has quoted it.
|
|
213
|
+
|
|
214
|
+
> **NOTE:** How a wholesale buyer **pays** — deposits, net terms, credit limits — is a
|
|
215
|
+
> separate subject and is not part of freight in 6.0. Partial payments are
|
|
216
|
+
> generally available and predate this work. See
|
|
217
|
+
> [payments](payments.md).
|
|
218
|
+
|
|
219
|
+
## Shipment tiers
|
|
220
|
+
|
|
221
|
+
Which freight method a load qualifies for is configuration, not code. Each
|
|
222
|
+
tier — "Cartons", "Pallet", "20ft container" — is an ordinary delivery method
|
|
223
|
+
bounded by a rule.
|
|
224
|
+
|
|
225
|
+
**Volume rule.** Bounds the method by packed volume in cubic meters: a pallet
|
|
226
|
+
method takes 1–15 CBM, a 20ft container 15–33, and the estimator offers
|
|
227
|
+
whichever one the order actually fills.
|
|
228
|
+
|
|
229
|
+
Its minimum is deliberately forgiving. A partly measured catalog understates
|
|
230
|
+
the load, so the figure is a floor rather than a measurement — it may raise a
|
|
231
|
+
shipment into a tier but never exclude it from one. A maximum still applies,
|
|
232
|
+
since passing one on too small a number errs toward offering the method.
|
|
233
|
+
|
|
234
|
+
**Company rule.** Splits freight from parcel by who is buying. On, the method
|
|
235
|
+
is offered only to orders placed for a [company](companies.md),
|
|
236
|
+
so a carton tier disappears from retail carts. Off, only to orders that are
|
|
237
|
+
not, which keeps parcel methods away from wholesale buyers. A method with no
|
|
238
|
+
company rule is offered to both, so the rule is only ever added to state a
|
|
239
|
+
split.
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
```typescript Admin SDK
|
|
243
|
+
await adminClient.deliveryMethods.create({
|
|
244
|
+
name: 'Pallet freight',
|
|
245
|
+
rate_provider: 'Spree::DeliveryRateProvider::Freight',
|
|
246
|
+
rules: [
|
|
247
|
+
{ type: 'volume_rule', preferences: { minimum_volume: 1, maximum_volume: 15 } },
|
|
248
|
+
{ type: 'company_rule', preferences: { company_orders_only: true } },
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
```bash cURL
|
|
254
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/delivery_methods' \
|
|
255
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
256
|
+
-H 'Content-Type: application/json' \
|
|
257
|
+
-d '{
|
|
258
|
+
"name": "Pallet freight",
|
|
259
|
+
"rate_provider": "Spree::DeliveryRateProvider::Freight",
|
|
260
|
+
"rules": [
|
|
261
|
+
{ "type": "volume_rule", "preferences": { "minimum_volume": 1, "maximum_volume": 15 } },
|
|
262
|
+
{ "type": "company_rule", "preferences": { "company_orders_only": true } }
|
|
263
|
+
]
|
|
264
|
+
}'
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
A freight method never consults a calculator — there is no price to work out —
|
|
269
|
+
so admin surfaces hide its pricing form. `GET /api/v3/admin/delivery_methods/rate_providers`
|
|
270
|
+
lists the providers installed on the store, which is what a picker should read
|
|
271
|
+
rather than a hardcoded list.
|
|
272
|
+
|
|
273
|
+
## Related
|
|
274
|
+
|
|
275
|
+
- [Delivery setup](delivery-setup.md) — profiles, zones, methods and package types
|
|
276
|
+
- [Fulfillments](fulfillments.md) — what happens to the consignment afterwards
|
|
277
|
+
- [Companies](companies.md) — who a wholesale order is placed for
|
|
278
|
+
- [Build a B2B store](../how-to/build-a-b2b-store.md) — the wholesale setup end to end
|
|
279
|
+
- [Package types](/user/settings/package-types) — configuring packaging in the dashboard
|
|
@@ -11,7 +11,7 @@ Payment methods typically represent a payment gateway. Gateways will process car
|
|
|
11
11
|
|
|
12
12
|
The `Payment` model in Spree tracks payments against [Orders](orders.md). Payments relate to a `source` which indicates how the payment was made, and a `PaymentMethod`, indicating the processor used for this payment.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
A payment's `number` is derived from what it belongs to — an order `R1001` numbers its payments `R1001-P1`, `R1001-P2` — so a gateway reference traces straight back to the order without a lookup.
|
|
15
15
|
|
|
16
16
|
### Payment Architecture Diagram
|
|
17
17
|
|
|
@@ -20,9 +20,8 @@ erDiagram
|
|
|
20
20
|
Payment {
|
|
21
21
|
string number
|
|
22
22
|
decimal amount
|
|
23
|
-
string
|
|
23
|
+
string status
|
|
24
24
|
string response_code
|
|
25
|
-
string avs_response
|
|
26
25
|
string source_type
|
|
27
26
|
string source_id
|
|
28
27
|
}
|
|
@@ -32,7 +31,7 @@ erDiagram
|
|
|
32
31
|
string type
|
|
33
32
|
string description
|
|
34
33
|
boolean active
|
|
35
|
-
|
|
34
|
+
boolean storefront_visible
|
|
36
35
|
integer position
|
|
37
36
|
}
|
|
38
37
|
|
|
@@ -58,11 +57,12 @@ erDiagram
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
CreditCard {
|
|
61
|
-
string
|
|
62
|
-
string
|
|
60
|
+
string last4
|
|
61
|
+
string brand
|
|
63
62
|
integer month
|
|
64
63
|
integer year
|
|
65
64
|
string name
|
|
65
|
+
boolean default
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
GatewayCustomer {
|
|
@@ -124,12 +124,12 @@ A `PaymentMethod` can have the following attributes:
|
|
|
124
124
|
| `name` | The visible name for this payment method | `Check` |
|
|
125
125
|
| `description`| The description for this payment method | `Pay by check.` |
|
|
126
126
|
| `active` | Whether or not this payment method is active. Set it `false` to hide it in the Store API. | `true` |
|
|
127
|
-
| `
|
|
127
|
+
| `storefront_visible` | Whether customers can choose this method. Leave it off for a method only staff use. | `true` |
|
|
128
128
|
| `position` | The position of the payment method in lists. Lower numbers appear first. | `1` |
|
|
129
129
|
|
|
130
130
|
> **INFO:** Each payment method is associated to a Store, so you can decide which Payment Method will appear on which Store. This allows you to create different experiences for your customers in different countries.
|
|
131
131
|
|
|
132
|
-
### Session-based vs
|
|
132
|
+
### Session-based vs direct payment methods
|
|
133
133
|
|
|
134
134
|
Payment methods indicate whether they use the modern session-based flow via the `session_required?` method:
|
|
135
135
|
|
|
@@ -140,13 +140,13 @@ Payment methods indicate whether they use the modern session-based flow via the
|
|
|
140
140
|
| `payment_session_class` | Returns the STI subclass of `Spree::PaymentSession` for this gateway (e.g., `Spree::PaymentSessions::Stripe`). | `nil` |
|
|
141
141
|
| `payment_setup_session_class` | Returns the STI subclass of `Spree::PaymentSetupSession` for this gateway. | `nil` |
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
Gateways like Stripe and Adyen set `session_required?` to `true`; offline methods leave it `false`. Neither path is deprecated. The Store API serializer includes this as the `session_required` field so your frontend knows which flow to use.
|
|
144
144
|
|
|
145
|
-
###
|
|
145
|
+
### Direct payment methods (manual and offline)
|
|
146
146
|
|
|
147
147
|
Payment methods where `session_required?` returns `false` don't need a payment session. These are typically offline or manual payment methods such as:
|
|
148
148
|
|
|
149
|
-
- **Check** — built
|
|
149
|
+
- **Check** — built in
|
|
150
150
|
- **Cash on Delivery** — customer pays upon delivery
|
|
151
151
|
- **Bank Transfer / Wire** — customer transfers money to a bank account
|
|
152
152
|
- **Purchase Order** — common in B2B, customer provides a PO number
|
|
@@ -180,9 +180,16 @@ curl -X POST 'https://api.mystore.com/api/v3/store/carts/cart_xxx/payments' \
|
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
The payment
|
|
183
|
+
The payment starts at `checkout`. Completing the cart processes it — for a manual method there is no provider to call, so it succeeds immediately and lands on `pending`, or `completed` when the store charges at checkout.
|
|
184
|
+
|
|
185
|
+
Once the money actually arrives — the cheque clears, the transfer lands, the
|
|
186
|
+
driver is paid — staff capture the payment from the dashboard, or you do it
|
|
187
|
+
through the Admin API:
|
|
184
188
|
|
|
185
|
-
|
|
189
|
+
```typescript Admin SDK
|
|
190
|
+
await adminClient.orders.payments.capture(orderId, paymentId)
|
|
191
|
+
await adminClient.orders.payments.void(orderId, paymentId)
|
|
192
|
+
```
|
|
186
193
|
|
|
187
194
|
## Payment Flow
|
|
188
195
|
|
|
@@ -247,7 +254,7 @@ After the customer completes payment, the frontend calls the Complete Payment Se
|
|
|
247
254
|
|
|
248
255
|
**Step 4: Complete Order**
|
|
249
256
|
|
|
250
|
-
The frontend calls `POST /carts/:id/complete` to finalize the order. Spree
|
|
257
|
+
The frontend calls `POST /carts/:id/complete` to finalize the order. Spree checks the cart has everything it needs — addresses, fulfillments, payment — and turns it into an order.
|
|
251
258
|
|
|
252
259
|
This separation ensures the same flow works for all payment types — inline cards, offsite redirects, and wallet payments.
|
|
253
260
|
|
|
@@ -305,11 +312,11 @@ sequenceDiagram
|
|
|
305
312
|
Spree API-->>Frontend: Cart (with embedded payment_methods, each carrying session_required)
|
|
306
313
|
|
|
307
314
|
Frontend->>Spree API: POST /payments (payment_method_id)
|
|
308
|
-
Spree API->>Spree API: Create
|
|
315
|
+
Spree API->>Spree API: Create payment (status checkout)
|
|
309
316
|
Spree API-->>Frontend: Payment created
|
|
310
317
|
|
|
311
318
|
Frontend->>Spree API: POST /carts/:id/complete
|
|
312
|
-
Spree API->>Spree API:
|
|
319
|
+
Spree API->>Spree API: Process payment (succeeds immediately)
|
|
313
320
|
Spree API->>Spree API: Payment → pending/completed
|
|
314
321
|
Spree API-->>Frontend: Order completed
|
|
315
322
|
```
|
|
@@ -320,16 +327,18 @@ The frontend reads the cart's embedded `payment_methods` (returned by `GET /cart
|
|
|
320
327
|
|
|
321
328
|
**Step 2: Create payment**
|
|
322
329
|
|
|
323
|
-
The frontend calls `POST /payments` with the `payment_method_id`. Spree creates a
|
|
330
|
+
The frontend calls `POST /payments` with the `payment_method_id`. Spree creates a payment with status `checkout`. No provider is contacted.
|
|
331
|
+
|
|
332
|
+
Two refusals to handle: a method needing a session returns `payment_session_required`, and one unavailable for this order returns `payment_method_unavailable` — both HTTP 422 with that `code`.
|
|
324
333
|
|
|
325
334
|
**Step 3: Complete order**
|
|
326
335
|
|
|
327
|
-
The frontend completes the order.
|
|
336
|
+
The frontend completes the order. Completing the cart processes the payment. For a manual method there is nothing to call, so it succeeds immediately and lands on `pending`, or `completed` when the store charges at checkout. Staff capture `pending` payments from the dashboard once the money arrives.
|
|
328
337
|
|
|
329
338
|
|
|
330
339
|
### Payment Session
|
|
331
340
|
|
|
332
|
-
A
|
|
341
|
+
A payment session represents a server-side session with the payment gateway. It is the entry point for every payment attempt and holds the provider-specific data needed by the frontend SDK.
|
|
333
342
|
|
|
334
343
|
#### Attributes
|
|
335
344
|
|
|
@@ -342,6 +351,11 @@ A `PaymentSession` (`Spree::PaymentSession`) represents a server-side session wi
|
|
|
342
351
|
| `external_data` | Provider-specific data including `client_secret` for frontend SDK | `{"client_secret": "pi_3ABC_secret_xyz"}` |
|
|
343
352
|
| `customer_external_id` | The provider's customer ID | `cus_ABC123` |
|
|
344
353
|
| `expires_at` | When the session expires | `2025-01-01T12:00:00Z` |
|
|
354
|
+
| `cart_id` | The cart being paid for | `cart_k5nR8xLq` |
|
|
355
|
+
|
|
356
|
+
> **NOTE:** A session also carries `order_id`, but during checkout there is no order yet —
|
|
357
|
+
> it reports the cart's ID until the cart completes. Read `cart_id` while paying,
|
|
358
|
+
> and `order_id` once you have an order.
|
|
345
359
|
|
|
346
360
|
#### States
|
|
347
361
|
|
|
@@ -359,6 +373,10 @@ stateDiagram-v2
|
|
|
359
373
|
processing --> expired
|
|
360
374
|
```
|
|
361
375
|
|
|
376
|
+
> **NOTE:** There is no cancel endpoint. A session reaches `canceled` when the provider
|
|
377
|
+
> says so through its webhook, and `expired` when it passes `expires_at`.
|
|
378
|
+
> Neither is something a storefront drives.
|
|
379
|
+
|
|
362
380
|
#### API
|
|
363
381
|
|
|
364
382
|
**Create a Payment Session:**
|
|
@@ -441,7 +459,7 @@ console.log(completed.status) // 'completed'
|
|
|
441
459
|
```
|
|
442
460
|
|
|
443
461
|
```bash cURL
|
|
444
|
-
curl -X
|
|
462
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx/payment_sessions/ps_xxx/complete' \
|
|
445
463
|
-H 'X-Spree-API-Key: pk_xxx' \
|
|
446
464
|
-H 'X-Spree-Token: CART_TOKEN' \
|
|
447
465
|
-H 'Content-Type: application/json' \
|
|
@@ -487,10 +505,10 @@ class MyGateway < Spree::Gateway
|
|
|
487
505
|
|
|
488
506
|
case event.type
|
|
489
507
|
when 'payment.captured'
|
|
490
|
-
session =
|
|
508
|
+
session = payment_sessions.find_by(external_id: event.payment_id)
|
|
491
509
|
{ action: :captured, payment_session: session }
|
|
492
510
|
when 'payment.failed'
|
|
493
|
-
session =
|
|
511
|
+
session = payment_sessions.find_by(external_id: event.payment_id)
|
|
494
512
|
{ action: :failed, payment_session: session }
|
|
495
513
|
else
|
|
496
514
|
nil # unsupported event
|
|
@@ -503,48 +521,55 @@ Supported actions: `:captured`, `:authorized`, `:failed`, `:canceled`.
|
|
|
503
521
|
|
|
504
522
|
### Payment
|
|
505
523
|
|
|
506
|
-
Once a
|
|
524
|
+
Once a payment session completes, Spree creates a payment to record the result,
|
|
525
|
+
linked to the session by `response_code` matching its `external_id`.
|
|
507
526
|
|
|
508
527
|
#### Attributes
|
|
509
528
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
|
513
|
-
|
|
514
|
-
| `
|
|
515
|
-
| `
|
|
516
|
-
| `
|
|
517
|
-
| `
|
|
518
|
-
| `
|
|
519
|
-
| `
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
| `
|
|
542
|
-
|
|
543
|
-
| `
|
|
544
|
-
| `
|
|
545
|
-
| `
|
|
546
|
-
|
|
547
|
-
|
|
529
|
+
These are the fields the Store API returns:
|
|
530
|
+
|
|
531
|
+
| Attribute | Description | Example |
|
|
532
|
+
|---|---|---|
|
|
533
|
+
| `number` | Derived from the owner — see above. | `R1001-P1` |
|
|
534
|
+
| `status` | Where the payment stands. | `completed` |
|
|
535
|
+
| `amount` / `display_amount` | The amount, raw and formatted for its currency. | `99.99` / `$99.99` |
|
|
536
|
+
| `payment_method_id` | Which method took it. | `pm_xyz789` |
|
|
537
|
+
| `source_type` | `credit_card`, `store_credit` or `payment_source`. | `credit_card` |
|
|
538
|
+
| `source_id` | The source's ID. | `cc_abc123` |
|
|
539
|
+
| `source` | The source itself, embedded. | `{ brand: "visa", last4: "4242" }` |
|
|
540
|
+
| `response_code` | The gateway's transaction reference. | `pi_3ABC123` |
|
|
541
|
+
|
|
542
|
+
> **NOTE:** `source_type` is a plain name — `credit_card`, not a class name — so you can
|
|
543
|
+
> switch on it directly.
|
|
544
|
+
|
|
545
|
+
#### Payment statuses
|
|
546
|
+
|
|
547
|
+
| Status | Meaning |
|
|
548
|
+
|---|---|
|
|
549
|
+
| `checkout` | Created, not yet processed. |
|
|
550
|
+
| `processing` | Being processed. Brief, and it stops a double submission. |
|
|
551
|
+
| `pending` | Authorized but not captured. Waiting for capture. |
|
|
552
|
+
| `completed` | Captured. Only these count towards the order total. |
|
|
553
|
+
| `failed` | The gateway rejected it. |
|
|
554
|
+
| `void` | Voided; it no longer counts against the order. |
|
|
555
|
+
| `invalid` | Superseded — written to an old checkout payment when a new one replaces it. |
|
|
556
|
+
|
|
557
|
+
When the money is taken follows the store's `capture_method`, which a payment
|
|
558
|
+
method can override for its own payments:
|
|
559
|
+
|
|
560
|
+
| `capture_method` | What happens |
|
|
561
|
+
|---|---|
|
|
562
|
+
| `checkout` | Charged when the order is placed — the payment goes straight to `completed`. |
|
|
563
|
+
| `on_dispatch` | Authorized at checkout, captured when the goods go out. |
|
|
564
|
+
| `manual` | Authorized at checkout; staff capture it when they choose. |
|
|
565
|
+
|
|
566
|
+
The last two leave the payment at `pending` until capture. See
|
|
567
|
+
[Configuration](../customization/configuration.md) for where the store
|
|
568
|
+
setting lives.
|
|
569
|
+
|
|
570
|
+
> **NOTE:** There is no state machine behind these. A payment's status is written by the
|
|
571
|
+
> workflows that process, capture and void it, so nothing transitions on its own
|
|
572
|
+
> and there is no sequence a client has to drive.
|
|
548
573
|
|
|
549
574
|
#### Order Payment Status
|
|
550
575
|
|
|
@@ -563,11 +588,27 @@ Each payment update also recalculates the order's `payment_status`, derived from
|
|
|
563
588
|
|
|
564
589
|
> **WARNING:** Keep an eye on orders sitting at `authorized` or `partially_paid` long after placement — a sudden increase can indicate a problem with your payment gateway that is affecting customers. Check the gateway's own dashboard for recent transactions.
|
|
565
590
|
|
|
591
|
+
## Refunds
|
|
592
|
+
|
|
593
|
+
Refunds are an Admin API operation — there is no Store API route, so a customer
|
|
594
|
+
cannot start one from your storefront:
|
|
595
|
+
|
|
596
|
+
```typescript Admin SDK
|
|
597
|
+
await adminClient.orders.refunds.create(orderId, {
|
|
598
|
+
payment_id: 'py_abc123',
|
|
599
|
+
amount: '25.00',
|
|
600
|
+
reason_id: 'rr_xyz789',
|
|
601
|
+
})
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
A refund publishes `payment.refunded` and moves the order's `payment_status` to
|
|
605
|
+
`partially_refunded` or `refunded`.
|
|
606
|
+
|
|
566
607
|
## Payment Sources
|
|
567
608
|
|
|
568
609
|
Payment sources represent the actual instrument used for a payment. They are created automatically when a Payment Session completes.
|
|
569
610
|
|
|
570
|
-
###
|
|
611
|
+
### Saved cards
|
|
571
612
|
|
|
572
613
|
Stores non-sensitive credit card information. With modern gateways, the actual card data is tokenized by the provider - Spree only stores reference IDs and display information.
|
|
573
614
|
|
|
@@ -575,9 +616,10 @@ Stores non-sensitive credit card information. With modern gateways, the actual c
|
|
|
575
616
|
|---------------------|-----------------------------------------------------------------------------------------------|------------------------|
|
|
576
617
|
| `month` | The month the credit card expires. | `6` |
|
|
577
618
|
| `year` | The year the credit card expires. | `2026` |
|
|
578
|
-
| `
|
|
579
|
-
| `
|
|
580
|
-
| `name` | The name
|
|
619
|
+
| `brand` | The card brand. | `visa` |
|
|
620
|
+
| `last4` | The last four digits. | `4242` |
|
|
621
|
+
| `name` | The cardholder's name. | `John Doe` |
|
|
622
|
+
| `default` | Whether this is the customer's default card. | `true` |
|
|
581
623
|
| `gateway_payment_profile_id` | The payment token from the gateway (e.g., Stripe `pm_xxx`, Adyen `storedPaymentMethodId`). | `pm_1ABC123` |
|
|
582
624
|
|
|
583
625
|
> **NOTE:** Spree never stores full credit card numbers. With modern gateways, card data is collected entirely by the gateway's frontend SDK (e.g., Stripe.js, Adyen Drop-in) and never touches your server. Spree only stores the tokenized reference (`gateway_payment_profile_id`) returned by the provider.
|
|
@@ -586,7 +628,7 @@ Stores non-sensitive credit card information. With modern gateways, the actual c
|
|
|
586
628
|
|
|
587
629
|
A generic payment source model for non-card payment methods such as digital wallets, bank transfers, and buy-now-pay-later services. Gateway integrations create subtypes for each payment method type (e.g., Klarna, Afterpay, iDEAL, Apple Pay, Google Pay, PayPal).
|
|
588
630
|
|
|
589
|
-
### Gateway
|
|
631
|
+
### Gateway customers
|
|
590
632
|
|
|
591
633
|
Maps a Spree customer to their provider-specific customer profile. This enables features like saved payment methods, recurring billing, and customer-level fraud detection.
|
|
592
634
|
|
|
@@ -594,13 +636,16 @@ Maps a Spree customer to their provider-specific customer profile. This enables
|
|
|
594
636
|
|-----------|-------------|---------------|
|
|
595
637
|
| `profile_id` | The provider's customer ID (encrypted at rest) | `cus_ABC123` |
|
|
596
638
|
| `payment_method_id` | The gateway this customer belongs to | `1` |
|
|
597
|
-
| `
|
|
639
|
+
| `customer_id` | The Spree customer | `42` |
|
|
640
|
+
|
|
641
|
+
A customer has at most one record per payment method, and `profile_id` is encrypted with Active Record Encryption where it is configured.
|
|
598
642
|
|
|
599
|
-
|
|
643
|
+
> **NOTE:** This one is internal — the API does not expose it. It is listed here because
|
|
644
|
+
> gateway integrations rely on it; nothing a storefront does will read it.
|
|
600
645
|
|
|
601
646
|
## Payment Setup Sessions
|
|
602
647
|
|
|
603
|
-
Payment
|
|
648
|
+
Payment setup sessions let customers save payment methods for future use **without making an immediate payment**. This maps to concepts like Stripe's SetupIntent - a secure way to collect and tokenize payment details for later charges.
|
|
604
649
|
|
|
605
650
|
### Use Cases
|
|
606
651
|
|
|
@@ -714,7 +759,7 @@ console.log(completed.payment_source_id) // e.g. 'card_xyz789' - the saved paym
|
|
|
714
759
|
```
|
|
715
760
|
|
|
716
761
|
```bash cURL
|
|
717
|
-
curl -X
|
|
762
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/customers/me/payment_setup_sessions/pss_abc123/complete' \
|
|
718
763
|
-H 'X-Spree-API-Key: pk_xxx' \
|
|
719
764
|
-H 'Authorization: Bearer $CUSTOMER_JWT' \
|
|
720
765
|
-H 'Content-Type: application/json' \
|
|
@@ -722,7 +767,7 @@ curl -X POST 'https://api.mystore.com/api/v3/store/customers/me/payment_setup_se
|
|
|
722
767
|
```
|
|
723
768
|
|
|
724
769
|
|
|
725
|
-
Spree
|
|
770
|
+
Spree verifies the result with the provider and saves a payment source — usually a card — for future payments.
|
|
726
771
|
|
|
727
772
|
## Supported Gateways
|
|
728
773
|
|
|
@@ -741,7 +786,11 @@ Spree publishes events throughout the payment lifecycle that you can subscribe t
|
|
|
741
786
|
### Payment Events
|
|
742
787
|
| Event | Description |
|
|
743
788
|
|-------|-------------|
|
|
744
|
-
| `payment.
|
|
789
|
+
| `payment.completed` | Payment reached `completed` |
|
|
790
|
+
| `payment.paid` | Payment was paid |
|
|
791
|
+
| `payment.captured` | An authorized payment was captured |
|
|
792
|
+
| `payment.voided` | Payment was voided |
|
|
793
|
+
| `payment.refunded` | Payment was refunded |
|
|
745
794
|
| `order.paid` | Order is fully paid |
|
|
746
795
|
|
|
747
796
|
### Payment Session Events
|
|
@@ -756,9 +805,11 @@ Spree publishes events throughout the payment lifecycle that you can subscribe t
|
|
|
756
805
|
### Payment Setup Session Events
|
|
757
806
|
| Event | Description |
|
|
758
807
|
|-------|-------------|
|
|
808
|
+
| `payment_setup_session.processing` | Setup is being processed |
|
|
759
809
|
| `payment_setup_session.completed` | Setup completed, payment source saved |
|
|
760
810
|
| `payment_setup_session.failed` | Setup failed |
|
|
761
811
|
| `payment_setup_session.canceled` | Setup was canceled |
|
|
812
|
+
| `payment_setup_session.expired` | Setup expired |
|
|
762
813
|
|
|
763
814
|
See [Events](events.md) for more details on subscribing to events.
|
|
764
815
|
|
|
@@ -308,7 +308,21 @@ The scheduler is the host app's. Two jobs fan out per seller and the interval lo
|
|
|
308
308
|
| `Spree::SellerPayouts::SweepDueJob` | daily | settles every seller who is due |
|
|
309
309
|
| `Spree::SellerTransfers::ExecutePendingDueJob` | hourly | retries earnings a provider refused — usually a passing thing |
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
> **WARNING:** **You have to schedule these yourself** — a new project does not ship them, so
|
|
312
|
+
> a marketplace that skips this step never pays a seller. Add both to your
|
|
313
|
+
> scheduler; with Solid Queue that is `server/config/recurring.yml`:
|
|
314
|
+
>
|
|
315
|
+
> ```yaml server/config/recurring.yml
|
|
316
|
+
production:
|
|
317
|
+
sweep_due_seller_payouts:
|
|
318
|
+
class: Spree::SellerPayouts::SweepDueJob
|
|
319
|
+
schedule: every day at 3am
|
|
320
|
+
execute_pending_seller_transfers:
|
|
321
|
+
class: Spree::SellerTransfers::ExecutePendingDueJob
|
|
322
|
+
schedule: every hour
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A seller on the `manual` interval is skipped by the sweep and settled by hand instead:
|
|
312
326
|
|
|
313
327
|
|
|
314
328
|
```typescript Admin SDK
|
|
@@ -319,7 +333,7 @@ const { data: balances } = await adminClient.sellers.balances('sel_xxx')
|
|
|
319
333
|
await adminClient.sellers.settle('sel_xxx')
|
|
320
334
|
|
|
321
335
|
// The built-in provider: record that the bank transfer went out
|
|
322
|
-
await adminClient.sellerPayouts.complete('
|
|
336
|
+
await adminClient.sellerPayouts.complete('vpo_xxx', { reference: 'TRN-2026-0912-0042' })
|
|
323
337
|
```
|
|
324
338
|
|
|
325
339
|
```bash cURL
|
|
@@ -329,14 +343,43 @@ curl 'https://api.mystore.com/api/v3/admin/sellers/sel_xxx/balances' \
|
|
|
329
343
|
curl -X POST 'https://api.mystore.com/api/v3/admin/sellers/sel_xxx/payouts' \
|
|
330
344
|
-H 'X-Spree-API-Key: sk_xxx'
|
|
331
345
|
|
|
332
|
-
curl -X PATCH 'https://api.mystore.com/api/v3/admin/seller_payouts/
|
|
346
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/seller_payouts/vpo_xxx/complete' \
|
|
333
347
|
-H 'X-Spree-API-Key: sk_xxx' \
|
|
334
348
|
-H 'Content-Type: application/json' \
|
|
335
349
|
-d '{ "reference": "TRN-2026-0912-0042" }'
|
|
336
350
|
```
|
|
337
351
|
|
|
338
352
|
|
|
339
|
-
|
|
353
|
+
Settling answers `201` with the payouts it created, one per currency. When one
|
|
354
|
+
currency settles and another is refused it still answers `201`, listing what
|
|
355
|
+
failed under `meta.failures` — so check that rather than assuming success. With
|
|
356
|
+
nothing to settle it answers `422`.
|
|
357
|
+
|
|
358
|
+
### Reading the ledger
|
|
359
|
+
|
|
360
|
+
Across every seller, from the marketplace side:
|
|
361
|
+
|
|
362
|
+
```typescript Admin SDK
|
|
363
|
+
const { data: transfers } = await adminClient.sellerTransfers.list({ seller_id_eq: 'sel_xxx' })
|
|
364
|
+
const { data: payouts } = await adminClient.sellerPayouts.list({ status_eq: 'completed' })
|
|
365
|
+
|
|
366
|
+
const payout = await adminClient.sellerPayouts.get('vpo_xxx')
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
And from the seller's own panel, where every request is already scoped to the
|
|
370
|
+
seller signed in — which is why none of these takes a seller ID:
|
|
371
|
+
|
|
372
|
+
```typescript Seller SDK
|
|
373
|
+
// What I'm owed, one row per currency
|
|
374
|
+
const { data: balances } = await sellerClient.balances.list()
|
|
375
|
+
|
|
376
|
+
// What I earned, and which payout settled it
|
|
377
|
+
const { data: transfers } = await sellerClient.transfers.list({ payout_id_eq: 'vpo_xxx' })
|
|
378
|
+
const { data: payouts } = await sellerClient.payouts.list()
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
All of it is read-only: the ledger is written by fulfilment and the sweep, never
|
|
382
|
+
by a seller.
|
|
340
383
|
|
|
341
384
|
### Payout providers
|
|
342
385
|
|
|
@@ -347,7 +390,9 @@ A provider is a stateless class registered in `Spree.payout_providers` and chose
|
|
|
347
390
|
| `Spree::PayoutProvider::System` (default) | No — keeps the books | The operator marks each payout paid, with the bank reference |
|
|
348
391
|
| Stripe Connect (`spree_stripe`) | Yes — transfers each earning to the seller's connected account as goods ship, tied to the charge that funds it | A Stripe webhook, when the money lands |
|
|
349
392
|
|
|
350
|
-
The contract is small: `transfer!` credits one earning, `pay!` sends one settlement, `reverse!` takes back part of an earning after a refund, and every call carries an idempotency key derived from the ledger row so a retry finds the movement it already made. Providers that need the seller to hold an account with them answer `requires_payout_account?` and implement `onboarding_url` — which is what the **payout account** [onboarding requirement](#onboarding-requirements) drives.
|
|
393
|
+
The contract is small: `transfer!` credits one earning, `pay!` sends one settlement, `reverse!` takes back part of an earning after a refund, and every call carries an idempotency key derived from the ledger row so a retry finds the movement it already made. Providers that need the seller to hold an account with them answer `requires_payout_account?` and implement `onboarding_url` — which is what the **payout account** [onboarding requirement](#onboarding-requirements) drives. A seller gets a fresh link from `POST /seller/onboarding/payout_account`, and `GET /admin/payout_providers` lists what is registered, for a picker.
|
|
394
|
+
|
|
395
|
+
A connected provider confirms settlements through `POST /api/v3/webhooks/payouts/:payment_method_id` — separate from the payment webhook, because providers scope seller-account events to their own subscription and signing secret. To pay sellers through rails of your own, see [Seller payouts](../providers/payouts.md).
|
|
351
396
|
|
|
352
397
|
See [Stripe Connect for marketplaces](../../integrations/payments/stripe-connect.md) for the shipped provider.
|
|
353
398
|
|