@spree/docs 0.1.232 → 0.1.234
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/developer/core-concepts/commissions.md +5 -3
- package/dist/developer/core-concepts/payments.md +13 -9
- package/dist/developer/core-concepts/sellers.md +81 -4
- package/dist/developer/providers/overview.md +1 -0
- package/dist/developer/providers/payouts.md +327 -0
- package/dist/integrations/payments/stripe-connect.md +98 -0
- package/dist/integrations/payments/stripe.md +3 -12
- package/package.json +1 -1
|
@@ -310,13 +310,15 @@ There is **no write path**. Lines are read-only in the API, and correcting a cha
|
|
|
310
310
|
|
|
311
311
|
## What this doesn't do
|
|
312
312
|
|
|
313
|
-
Commission lines say what the marketplace charged. They don't move money.
|
|
313
|
+
Commission lines say what the marketplace charged. They don't move money, and they don't say what a seller is owed.
|
|
314
314
|
|
|
315
|
-
|
|
315
|
+
That is the [payout ledger's](sellers.md#payouts) job: when an order is fulfilled, a seller transfer is written for the sale *less these lines*, and payouts settle those transfers on the seller's schedule — by the built-in provider, which keeps the books and leaves the operator to pay by bank, or by Stripe Connect, which performs the transfer. A commission line is an input to that ledger, never a row in it.
|
|
316
|
+
|
|
317
|
+
> **NOTE:** Refund clawbacks and netting across settlements, reconciliation and seller tax reporting are Spree Enterprise.
|
|
316
318
|
|
|
317
319
|
## Related
|
|
318
320
|
|
|
319
|
-
- [Sellers](sellers.md) — marketplaces, order splitting and
|
|
321
|
+
- [Sellers](sellers.md) — marketplaces, order splitting, and the payout ledger commission feeds
|
|
320
322
|
- [Taxes](taxes.md) — the tax vocabulary commission lines share
|
|
321
323
|
- [Fees](fees.md) — buyer-facing charges, which commissions are not
|
|
322
324
|
- [Promotions](promotions.md) — the same rules-and-record split, on the buyer side
|
|
@@ -546,18 +546,22 @@ stateDiagram-v2
|
|
|
546
546
|
|
|
547
547
|
With **auto-capture** enabled (default for most gateways), the Payment goes directly from `checkout` → `processing` → `completed`. With **manual capture**, it stops at `pending` until an admin captures it.
|
|
548
548
|
|
|
549
|
-
#### Order Payment
|
|
549
|
+
#### Order Payment Status
|
|
550
550
|
|
|
551
|
-
Each payment update also recalculates the order's `
|
|
551
|
+
Each payment update also recalculates the order's `payment_status`, derived from its payments and refunds against the order total:
|
|
552
552
|
|
|
553
|
-
| Payment
|
|
553
|
+
| Payment status | Description |
|
|
554
554
|
|---------------|-------------|
|
|
555
|
-
| `
|
|
556
|
-
| `
|
|
557
|
-
| `
|
|
558
|
-
| `paid` |
|
|
559
|
-
|
|
560
|
-
|
|
555
|
+
| `none` | Nothing has been authorized or captured |
|
|
556
|
+
| `authorized` | Approved by the gateway, not yet captured |
|
|
557
|
+
| `partially_paid` | Captured less than the order total |
|
|
558
|
+
| `paid` | Captured payments cover the order total |
|
|
559
|
+
| `partially_refunded` | Refunded in part; the customer has paid something net |
|
|
560
|
+
| `refunded` | Refunds returned everything captured |
|
|
561
|
+
| `overcharged` | Captured more than the order total — the customer is owed the difference |
|
|
562
|
+
| `voided` | The order was canceled and its authorization released |
|
|
563
|
+
|
|
564
|
+
> **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.
|
|
561
565
|
|
|
562
566
|
## Payment Sources
|
|
563
567
|
|
|
@@ -270,11 +270,88 @@ See [Commissions](commissions.md) for rate targeting, the four rule types, per-c
|
|
|
270
270
|
|
|
271
271
|
## Payouts
|
|
272
272
|
|
|
273
|
-
Spree
|
|
273
|
+
Spree keeps a two-level ledger of what each seller is owed, and settles it on a schedule. Whether money actually moves is the payout provider's job; the books are kept either way.
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
```mermaid
|
|
276
|
+
flowchart LR
|
|
277
|
+
F["order.fulfilled"] --> T["SellerTransfer<br/><i>earning</i>"]
|
|
278
|
+
R["refund.created"] --> RV["SellerTransfer<br/><i>refund_reversal</i>"]
|
|
279
|
+
T --> P["SellerPayout<br/>swept on the seller's schedule"]
|
|
280
|
+
RV --> P
|
|
281
|
+
P --> C["complete<br/>operator, or provider webhook"]
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Transfers — what an order earned
|
|
285
|
+
|
|
286
|
+
A seller earns on **fulfillment**, not on payment: the marketplace holds the money until the goods ship, and a digital order fulfils immediately so it earns immediately. When `order.fulfilled` fires, a `Spree::SellerTransfer` of kind `earning` is written for the seller's sale less [commission](commissions.md), in the sale's currency.
|
|
287
|
+
|
|
288
|
+
The amount is payment-source-agnostic — store credit and gift cards are how the customer paid, which is the platform's funding concern, not the seller's.
|
|
289
|
+
|
|
290
|
+
A refund never edits an earning. `refund.created` writes a second row of kind `refund_reversal` against the same order, so what a seller has earned is always the sum of their transfers, and a reversal that lands after a payout closed falls into the next period rather than rewriting a settlement that already happened.
|
|
291
|
+
|
|
292
|
+
### Payouts — what was sent
|
|
293
|
+
|
|
294
|
+
On schedule, a seller's confirmed, unsettled earnings are batched into one `Spree::SellerPayout` per currency and handed to the provider. A payout names exactly which transfers it settled, which is what a seller needs to reconcile a deposit.
|
|
295
|
+
|
|
296
|
+
Both records share one status set: `pending` → `processing` → `completed`, or `failed` / `unresolved`. **Completing is what debits the balance** — a balance is earnings less *completed* settlements — and nothing completes a payout automatically at creation, because "the money arrived" is a claim about the outside world. An operator running the built-in provider marks it paid once the bank transfer is sent; a connected provider marks it paid when its webhook says so. Either way it publishes `seller_payout.completed`.
|
|
297
|
+
|
|
298
|
+
`unresolved` is deliberate: a send whose outcome nobody knows keeps its transfers rather than releasing them, since releasing is how the same earnings get sent twice.
|
|
299
|
+
|
|
300
|
+
### Schedule and thresholds
|
|
301
|
+
|
|
302
|
+
Each seller settles on their own interval — `daily`, `weekly`, `biweekly`, `monthly`, or `manual` — and only once their balance clears a minimum. Both fall back to the store's defaults (`default_payouts_schedule_interval`, `default_minimum_payout_amount`); below the minimum the balance simply carries forward.
|
|
303
|
+
|
|
304
|
+
The scheduler is the host app's. Two jobs fan out per seller and the interval logic lives inside them, since a cron expression cannot say "weekly, but from whenever *this* seller was last paid":
|
|
305
|
+
|
|
306
|
+
| Job | Run it | Does |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| `Spree::SellerPayouts::SweepDueJob` | daily | settles every seller who is due |
|
|
309
|
+
| `Spree::SellerTransfers::ExecutePendingDueJob` | hourly | retries earnings a provider refused — usually a passing thing |
|
|
310
|
+
|
|
311
|
+
`spree/spree-starter` ships both entries in `server/config/recurring.yml`. A seller on the `manual` interval is skipped by the sweep and settled by hand instead:
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
```typescript Admin SDK
|
|
315
|
+
// Where the seller stands, one row per currency
|
|
316
|
+
const { data: balances } = await adminClient.sellers.balances('sel_xxx')
|
|
317
|
+
|
|
318
|
+
// Settle them now, whatever their schedule says
|
|
319
|
+
await adminClient.sellers.settle('sel_xxx')
|
|
320
|
+
|
|
321
|
+
// The built-in provider: record that the bank transfer went out
|
|
322
|
+
await adminClient.sellerPayouts.complete('spo_xxx', { reference: 'TRN-2026-0912-0042' })
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```bash cURL
|
|
326
|
+
curl 'https://api.mystore.com/api/v3/admin/sellers/sel_xxx/balances' \
|
|
327
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
328
|
+
|
|
329
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/sellers/sel_xxx/payouts' \
|
|
330
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
331
|
+
|
|
332
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/seller_payouts/spo_xxx/complete' \
|
|
333
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
334
|
+
-H 'Content-Type: application/json' \
|
|
335
|
+
-d '{ "reference": "TRN-2026-0912-0042" }'
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
`GET /admin/seller_transfers` and `GET /admin/seller_payouts` list the ledger across every seller; the seller panel reads its own through `GET /seller/balances`, `/seller/transfers` and `/seller/payouts`, all read-only — the ledger is written by fulfilment and the sweep, never by the seller.
|
|
340
|
+
|
|
341
|
+
### Payout providers
|
|
342
|
+
|
|
343
|
+
A provider is a stateless class registered in `Spree.payout_providers` and chosen per store with the `payout_provider` preference. Core ships one:
|
|
344
|
+
|
|
345
|
+
| Provider | Moves money? | Completion |
|
|
346
|
+
|---|---|---|
|
|
347
|
+
| `Spree::PayoutProvider::System` (default) | No — keeps the books | The operator marks each payout paid, with the bank reference |
|
|
348
|
+
| 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
|
+
|
|
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. To pay sellers through rails of your own, see [Seller payouts](../providers/payouts.md).
|
|
351
|
+
|
|
352
|
+
See [Stripe Connect for marketplaces](../../integrations/payments/stripe-connect.md) for the shipped provider.
|
|
353
|
+
|
|
354
|
+
> **NOTE:** Refund clawbacks and netting across settlements, reconciliation, KYC operations and seller tax reporting (DAC7) are Spree Enterprise.
|
|
278
355
|
|
|
279
356
|
## The seller panel
|
|
280
357
|
|
|
@@ -34,6 +34,7 @@ Every connection point is a documented contract with a default implementation th
|
|
|
34
34
|
| WMS / 3PL / carriers | Fulfillment provider | [Shipping & WMS](fulfillment.md) |
|
|
35
35
|
| Carrier rate shopping | Delivery rate provider | [Custom delivery rates](../how-to/custom-delivery-rate-provider.md) |
|
|
36
36
|
| PSP | Payment gateway | [Custom payment method](../how-to/custom-payment-method.md) |
|
|
37
|
+
| Seller payouts | Payout provider | [Seller payouts](payouts.md) |
|
|
37
38
|
| Tax engine | Tax provider | [Avalara](../../integrations/tax/avalara.md) |
|
|
38
39
|
| Search | Search provider | [Custom search provider](../how-to/custom-search-provider.md) |
|
|
39
40
|
| Identity provider (SSO) | OpenID Connect | [Identity & SSO](sso.md) |
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Seller Payouts
|
|
3
|
+
description: Pay marketplace sellers through your own rails — a bank transfer API, a wallet, another PSP's platform product — by implementing the payout provider contract.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Spree keeps the marketplace's books on its own: what each seller earned as their orders shipped, and what has been settled to them. A **payout provider** is the part that actually moves the money. The built-in one moves nothing and leaves the operator to pay by bank; Stripe Connect moves it through connected accounts. This guide is for the third case — a provider of your own.
|
|
7
|
+
|
|
8
|
+
Read [how the ledger works](../core-concepts/sellers.md#payouts) first. Everything below assumes the vocabulary there: a *transfer* is one order's earning, a *payout* is a batch of them settled to the seller.
|
|
9
|
+
|
|
10
|
+
## What a provider decides
|
|
11
|
+
|
|
12
|
+
Core calls a provider at exactly three moments. Each verb takes the ledger row it is about and returns it:
|
|
13
|
+
|
|
14
|
+
| Verb | When core calls it | What you do |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `transfer!(seller_transfer)` | An order is fulfilled and the seller is payable | Credit the seller's earning — or, if your rails only move money at settlement, mark the row `completed` and do nothing else |
|
|
17
|
+
| `pay!(seller_payout)` | A scheduled sweep or an operator settles a seller | Send the batch. Store the provider's own id in `reference`, and leave the status alone — a payout is completed when the money is confirmed to have landed, not when it was sent |
|
|
18
|
+
| `reverse!(seller_transfer)` | A refund takes back part of an earning | Pull the money back if your rails can. Core writes the reversal row either way, so a provider that cannot claw back implements this as a no-op |
|
|
19
|
+
|
|
20
|
+
Alongside the verbs, a provider tells core what it needs from sellers before it can pay them:
|
|
21
|
+
|
|
22
|
+
| Method | Answers |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `self.requires_payout_account?` | Must a seller hold an account with you first? When `true`, core will not credit a seller until they are marked payable |
|
|
25
|
+
| `onboarding_url(seller, refresh_url:, return_url:)` | A fresh hosted link where the seller gives you what you need. Minted per call — these links expire. `nil` when you host nothing |
|
|
26
|
+
| `onboarded?(seller)` | Will you accept money for this seller right now? Ask your API; the answer can change without anything happening in Spree |
|
|
27
|
+
| `onboarding_state(seller)` | Why not yet: `:action` (the seller must do something), `:pending` (you are checking), `:rejected`, or `nil` |
|
|
28
|
+
| `onboarding_message(seller)` | Your own words about what is outstanding. Best-effort and unlocalized |
|
|
29
|
+
|
|
30
|
+
And three class-level descriptors: `display_name` (what the operator picks in settings), `reference_system` (the key a seller's account is filed under — a lowercase identifier, matching your `Spree::Integration` key if you ship one), and `available_for_store?(store)` (`false` until the store holds credentials for you). Leave `provider_key` alone: it is the full class name, and it is what the ledger's unique index on `(provider, reference)` keys on.
|
|
31
|
+
|
|
32
|
+
Providers are stateless and built with no arguments. Anything request-specific arrives as a parameter.
|
|
33
|
+
|
|
34
|
+
## Writing your own
|
|
35
|
+
|
|
36
|
+
The example below settles sellers by bank transfer through a fictional payments API. It moves money only at payout time, so `transfer!` is pure bookkeeping.
|
|
37
|
+
|
|
38
|
+
```ruby server/app/models/my_app/payout_provider.rb
|
|
39
|
+
module MyApp
|
|
40
|
+
class PayoutProvider < Spree::PayoutProvider::Base
|
|
41
|
+
def self.display_name
|
|
42
|
+
'Acme Bank Rails'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.reference_system
|
|
46
|
+
'acme'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.requires_payout_account?
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.available_for_store?(store)
|
|
54
|
+
store.integrations.active.exists?(type: 'MyApp::AcmeIntegration')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def onboarding_url(seller, refresh_url:, return_url:)
|
|
58
|
+
account_id = seller.payout_account_reference(self.class) ||
|
|
59
|
+
client(seller.store).accounts.create(email: seller.contact_email).id.tap do |id|
|
|
60
|
+
seller.set_payout_account_reference(self.class, id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
client(seller.store).onboarding_links.create(account: account_id, refresh_url:, return_url:).url
|
|
64
|
+
rescue Acme::Error => e
|
|
65
|
+
raise Spree::Core::GatewayError, e.message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def onboarded?(seller)
|
|
69
|
+
account = client(seller.store).accounts.retrieve(seller.payout_account_reference(self.class))
|
|
70
|
+
account.verified?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def transfer!(seller_transfer)
|
|
74
|
+
seller_transfer.update!(status: 'completed')
|
|
75
|
+
seller_transfer
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pay!(seller_payout)
|
|
79
|
+
seller = seller_payout.seller
|
|
80
|
+
transfer = client(seller.store).transfers.create(
|
|
81
|
+
amount: seller_payout.amount,
|
|
82
|
+
currency: seller_payout.currency,
|
|
83
|
+
destination: seller.payout_account_reference(self.class),
|
|
84
|
+
idempotency_key: idempotency_key(seller_payout)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
seller_payout.update!(reference: transfer.id)
|
|
88
|
+
seller_payout
|
|
89
|
+
rescue Acme::TimeoutError, Acme::ServerError => e
|
|
90
|
+
raise Spree::Core::AmbiguousGatewayError, e.message
|
|
91
|
+
rescue Acme::Error => e
|
|
92
|
+
raise Spree::Core::GatewayError, e.message
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reverse!(seller_transfer)
|
|
96
|
+
seller_transfer.update!(status: 'completed')
|
|
97
|
+
seller_transfer
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def client(store)
|
|
103
|
+
Acme::Client.new(api_key: store.integrations.active.find_by!(type: 'MyApp::AcmeIntegration').preferred_api_key)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Credentials come from a [Spree integration](../how-to/custom-delivery-rate-provider.md#1-store-credentials-in-an-integration), never from environment variables — a marketplace may run several stores, and each pays from its own account. The one the provider reads above holds the API key and the secret your webhooks are signed with:
|
|
110
|
+
|
|
111
|
+
```ruby server/app/models/my_app/acme_integration.rb
|
|
112
|
+
module MyApp
|
|
113
|
+
class AcmeIntegration < Spree::Integration
|
|
114
|
+
preference :api_key, :password
|
|
115
|
+
preference :webhook_secret, :password
|
|
116
|
+
|
|
117
|
+
def self.integration_group = 'payments'
|
|
118
|
+
|
|
119
|
+
def can_connect?
|
|
120
|
+
Acme::Client.new(api_key: preferred_api_key).ping
|
|
121
|
+
true
|
|
122
|
+
rescue Acme::Error => e
|
|
123
|
+
self.connection_error_message = e.message
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Register it
|
|
131
|
+
|
|
132
|
+
```ruby server/config/initializers/spree.rb
|
|
133
|
+
Spree.integrations << 'MyApp::AcmeIntegration'
|
|
134
|
+
Spree.payout_providers << MyApp::PayoutProvider
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The operator then picks it under **Settings → Payouts**, which is the store's `payout_provider` preference. The picker is built from the registry, so your provider appears with the two built-in ones and reports whether the store can use it today:
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
```typescript Admin SDK
|
|
141
|
+
const { data: providers } = await adminClient.payoutProviders.list()
|
|
142
|
+
// [{ id: 'MyApp::PayoutProvider', name: 'Acme Bank Rails', available: true,
|
|
143
|
+
// requires_payout_account: true, default: false }, ...]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```bash cURL
|
|
147
|
+
curl https://your-store.com/api/v3/admin/payout_providers \
|
|
148
|
+
-H "X-Spree-API-Key: sk_xxx"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
A store whose configured provider disappears from the registry — a gem removed, a class renamed — silently falls back to the built-in one so the ledger keeps recording. Rename with `def self.provider_key` pinned to the old name if you ever need to, or the rows written under the old name stop matching.
|
|
153
|
+
|
|
154
|
+
## Errors decide what happens next
|
|
155
|
+
|
|
156
|
+
Core reads two exceptions, and which one you raise decides whether the money is offered to your API again:
|
|
157
|
+
|
|
158
|
+
| You raise | What it means | What core does |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `Spree::Core::GatewayError` (or any other `StandardError`) | A definite refusal — the money did not move | An earning stays retryable and `SellerTransfers::ExecutePendingDueJob` asks again on its hourly run. A payout is marked `failed` and its earnings are released for the next sweep |
|
|
161
|
+
| `Spree::Core::AmbiguousGatewayError` | Nobody knows — a timeout, a 5xx, an idempotency conflict | The row is parked as `unresolved`. Nothing automatic touches it again; an operator resolves it against your books, or your webhook does |
|
|
162
|
+
|
|
163
|
+
Get this right. Raising a plain error on a timeout is how the same settlement is sent twice: the payout is failed, its earnings go back on the pile, and the next sweep batches them into a new payout with a new idempotency key — one your provider has never seen.
|
|
164
|
+
|
|
165
|
+
## Idempotency
|
|
166
|
+
|
|
167
|
+
Every call carries `idempotency_key(record)` — `spree-<prefixed id>` of the ledger row. Pass it to your API on every write. A crash between your API answering and Spree committing is retried, and the retry must find the movement it already made rather than make a second.
|
|
168
|
+
|
|
169
|
+
If your rails have no idempotency keys, look the movement up by that key (or by the row's prefixed id in your own metadata) before creating it.
|
|
170
|
+
|
|
171
|
+
## Seller accounts
|
|
172
|
+
|
|
173
|
+
A seller's account with your provider is stored as an [external reference](erp.md#addressing-records-by-your-keys) filed under your `reference_system`, so a marketplace that migrates between providers keeps every account on record:
|
|
174
|
+
|
|
175
|
+
| Need | Call |
|
|
176
|
+
|---|---|
|
|
177
|
+
| Record the account you opened | `seller.set_payout_account_reference(MyApp::PayoutProvider, 'acct_123')` |
|
|
178
|
+
| Read it back | `seller.payout_account_reference(MyApp::PayoutProvider)` |
|
|
179
|
+
| Find the seller a webhook is about | `Spree::Seller.with_payout_account(store, MyApp::PayoutProvider, 'acct_123').first` |
|
|
180
|
+
|
|
181
|
+
The reverse lookup is a uniquely indexed read, scoped to the store's own sellers — a webhook naming an account from another store finds nothing.
|
|
182
|
+
|
|
183
|
+
## Onboarding
|
|
184
|
+
|
|
185
|
+
When a store's provider requires an account, the operator adds the **Payout account** row to the seller [onboarding checklist](../core-concepts/sellers.md#onboarding-requirements). That row reads your `onboarded?` and `onboarding_state`, and the seller panel's button asks for a link at the moment it is clicked:
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
```typescript Seller SDK
|
|
189
|
+
const { url } = await sellerClient.onboarding.payoutAccount({
|
|
190
|
+
refresh_url: 'https://sellers.your-store.com/onboarding?refresh=1',
|
|
191
|
+
return_url: 'https://sellers.your-store.com/onboarding',
|
|
192
|
+
})
|
|
193
|
+
// url is null when the provider hosts no onboarding of its own
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```bash cURL
|
|
197
|
+
curl -X POST https://your-store.com/api/v3/seller/onboarding/payout_account \
|
|
198
|
+
-H "Authorization: Bearer <seller_jwt>" \
|
|
199
|
+
-H "X-Spree-Seller-Id: sel_xxx" \
|
|
200
|
+
-H "Content-Type: application/json" \
|
|
201
|
+
-d '{"refresh_url": "https://sellers.your-store.com/onboarding?refresh=1", "return_url": "https://sellers.your-store.com/onboarding"}'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
That request lands in your `onboarding_url`. A `Spree::Core::GatewayError` raised there becomes a 422 the panel can show; anything else is a crash.
|
|
206
|
+
|
|
207
|
+
## Confirming from a webhook
|
|
208
|
+
|
|
209
|
+
Two things only your provider can tell Spree, and both usually arrive as webhooks: that a seller became payable, and that a payout landed or bounced. The Stripe provider receives them through the shipped `/api/v3/webhooks/payouts/:payment_method_id` route, which works for any provider that is also a payment method and implements `handle_payout_webhook(raw_body, headers)` on its gateway. A standalone provider adds its own endpoint to the host app.
|
|
210
|
+
|
|
211
|
+
Address it to the integration rather than the store: the integration is what holds the signing secret, and the store follows from it.
|
|
212
|
+
|
|
213
|
+
```ruby server/config/routes.rb
|
|
214
|
+
Rails.application.routes.draw do
|
|
215
|
+
post 'webhooks/acme/:integration_id', to: 'acme_webhooks#create'
|
|
216
|
+
|
|
217
|
+
mount Spree::Core::Engine, at: '/'
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```ruby server/app/controllers/acme_webhooks_controller.rb
|
|
222
|
+
# Events from the payout provider: POST /webhooks/acme/:integration_id
|
|
223
|
+
#
|
|
224
|
+
# Handled inline rather than in a job — marking a seller payable or a payout
|
|
225
|
+
# settled is a couple of database writes — and answered with a 5xx when they
|
|
226
|
+
# fail, so the provider redelivers. A 200 for work that did not happen is a
|
|
227
|
+
# settlement nobody will ever record.
|
|
228
|
+
class AcmeWebhooksController < ActionController::API
|
|
229
|
+
include ActionController::RateLimiting
|
|
230
|
+
|
|
231
|
+
rate_limit to: 120, within: 1.minute, store: Rails.cache, by: -> { request.remote_ip }
|
|
232
|
+
|
|
233
|
+
def create
|
|
234
|
+
integration = Spree::Integration.active.find_by_prefix_id!(params[:integration_id])
|
|
235
|
+
event = verified_event(integration)
|
|
236
|
+
|
|
237
|
+
case event['type']
|
|
238
|
+
when 'account.updated' then account_updated(integration.store, event['data'])
|
|
239
|
+
when 'transfer.paid' then payout_settled(integration.store, event['data'], paid: true)
|
|
240
|
+
when 'transfer.failed' then payout_settled(integration.store, event['data'], paid: false)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
head :ok
|
|
244
|
+
rescue Spree::Integration::WebhookSignatureError
|
|
245
|
+
head :unauthorized
|
|
246
|
+
rescue ActiveRecord::RecordNotFound
|
|
247
|
+
head :not_found
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
Rails.error.report(e, source: 'my_app.webhooks.acme')
|
|
250
|
+
head :internal_server_error
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
# Constant-time comparison against the secret this integration was given.
|
|
256
|
+
# Anything unsigned or signed for another store is a 401, so a
|
|
257
|
+
# misconfigured sender notices rather than being silently ignored.
|
|
258
|
+
def verified_event(integration)
|
|
259
|
+
expected = OpenSSL::HMAC.hexdigest('SHA256', integration.preferred_webhook_secret.to_s, request.raw_post)
|
|
260
|
+
given = request.headers['X-Acme-Signature'].to_s
|
|
261
|
+
raise Spree::Integration::WebhookSignatureError unless ActiveSupport::SecurityUtils.secure_compare(expected, given)
|
|
262
|
+
|
|
263
|
+
JSON.parse(request.raw_post)
|
|
264
|
+
rescue JSON::ParserError
|
|
265
|
+
raise Spree::Integration::WebhookSignatureError, 'Malformed webhook payload'
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The provider knows the account, not the seller. The reverse lookup is a
|
|
269
|
+
# uniquely indexed read scoped to this store, so an account id from another
|
|
270
|
+
# tenant finds nothing.
|
|
271
|
+
def seller_for(store, account_id)
|
|
272
|
+
Spree::Seller.with_payout_account(store, MyApp::PayoutProvider, account_id).first
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Whether the provider will now accept money for this seller. It can go
|
|
276
|
+
# back to false — expired documents, a closed account — and clearing the
|
|
277
|
+
# stamp makes Spree hold new earnings instead of promising money nothing
|
|
278
|
+
# can send. Becoming payable sends whatever they earned while unverified.
|
|
279
|
+
def account_updated(store, data)
|
|
280
|
+
seller = seller_for(store, data['account_id'])
|
|
281
|
+
return if seller.nil?
|
|
282
|
+
|
|
283
|
+
verified = data['verified'] == true
|
|
284
|
+
became_payable = verified && seller.payouts_enabled_at.nil?
|
|
285
|
+
|
|
286
|
+
seller.update!(payouts_enabled_at: verified ? (seller.payouts_enabled_at || Time.current) : nil)
|
|
287
|
+
Spree::SellerTransfers::ExecutePendingJob.perform_later(seller.id) if became_payable
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Matched on the reference stored in `pay!`, never on amount or recency: a
|
|
291
|
+
# redelivered event would otherwise skip the settlement it already completed
|
|
292
|
+
# and land on the next one owed. The workflow is idempotent against a second
|
|
293
|
+
# delivery and races safely with an operator marking the same payout paid.
|
|
294
|
+
def payout_settled(store, data, paid:)
|
|
295
|
+
seller = seller_for(store, data['account_id'])
|
|
296
|
+
return if seller.nil?
|
|
297
|
+
|
|
298
|
+
payout = seller.seller_payouts.find_by(provider: MyApp::PayoutProvider.provider_key, reference: data['transfer_id'])
|
|
299
|
+
return if payout.nil? || payout.completed?
|
|
300
|
+
|
|
301
|
+
if paid
|
|
302
|
+
Spree.seller_payout_complete_workflow.call(seller_payout: payout, reference: data['transfer_id'])
|
|
303
|
+
else
|
|
304
|
+
payout.fail!
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`fail!` releases the payout's earnings so the next sweep picks them up again. Completing publishes `seller_payout.completed`, which is what the seller's balance and their panel read.
|
|
311
|
+
|
|
312
|
+
## What to test
|
|
313
|
+
|
|
314
|
+
The Stripe provider's spec in the monorepo (`spree/providers/stripe/spec/models/spree_stripe/payout_provider_spec.rb`) is the reference battery. The cases worth copying:
|
|
315
|
+
|
|
316
|
+
- `pay!` passes the idempotency key and stores the provider's id as `reference`, leaving the status `pending`
|
|
317
|
+
- A timeout raises `AmbiguousGatewayError`, a refusal raises `GatewayError` — the two the ledger tells apart
|
|
318
|
+
- Amounts are sent in the units your API expects (`Spree::Money::Rounding.to_minor_units(amount, currency)` for cents)
|
|
319
|
+
- `onboarded?` asks the API rather than reading the cached stamp
|
|
320
|
+
|
|
321
|
+
> **NOTE:** Refund clawbacks netted across settlements, reconciliation against provider statements, KYC operations and seller tax reporting (DAC7) are Spree Enterprise. Open source ships the ledger, the contract, and the two providers above.
|
|
322
|
+
|
|
323
|
+
## Related
|
|
324
|
+
|
|
325
|
+
- [Sellers — payouts](../core-concepts/sellers.md#payouts) — the ledger, the schedule, and the admin API around it
|
|
326
|
+
- [Commissions](../core-concepts/commissions.md) — what a seller's earning is net of
|
|
327
|
+
- [Stripe Connect for marketplaces](../../integrations/payments/stripe-connect.md) — the shipped provider that moves money
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Stripe Connect for Marketplaces"
|
|
3
|
+
sidebarTitle: Stripe Connect
|
|
4
|
+
description: "Pay your sellers through Stripe Connect — hosted Express onboarding, a transfer to each seller as their goods ship, and settlements confirmed by webhook."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Stripe Connect is the payout provider that ships with Spree's Stripe integration. You charge customers on your own Stripe account, each seller holds a connected Express account, and Spree moves every seller's share across as their orders ship. Read [how the payout ledger works](../../developer/core-concepts/sellers.md#payouts) first — this page covers only what Stripe adds to it.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- A [Stripe payment method](stripe.md) on the store, active, with its keys entered. Connect uses the same account and credentials — there is nothing separate to connect.
|
|
12
|
+
- Connect enabled on that Stripe account (Stripe Dashboard → **Connect** → Get started). Spree creates Express accounts, so pick the platform profile Stripe suggests for marketplaces.
|
|
13
|
+
- Sellers on the store. If you have none yet, [invite one](/user/sellers/onboarding).
|
|
14
|
+
|
|
15
|
+
## Turn it on
|
|
16
|
+
|
|
17
|
+
Go to **Settings → Payouts** and choose **Stripe Connect** as the payout provider. The option is only offered once an active Stripe payment method exists on the store. The schedule and minimum on the same page decide when Spree settles each seller.
|
|
18
|
+
|
|
19
|
+
The same setting through the Admin API:
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
```typescript Admin SDK
|
|
23
|
+
const { data: providers } = await adminClient.payoutProviders.list()
|
|
24
|
+
// find the row with id 'SpreeStripe::PayoutProvider' — `available` says whether this store can use it
|
|
25
|
+
|
|
26
|
+
await adminClient.store.update({
|
|
27
|
+
preferred_payout_provider: 'SpreeStripe::PayoutProvider',
|
|
28
|
+
preferred_default_payouts_schedule_interval: 'weekly',
|
|
29
|
+
preferred_default_minimum_payout_amount: 50,
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash cURL
|
|
34
|
+
curl -X PATCH https://your-store.com/api/v3/admin/store \
|
|
35
|
+
-H "X-Spree-API-Key: sk_xxx" \
|
|
36
|
+
-H "Content-Type: application/json" \
|
|
37
|
+
-d '{"preferred_payout_provider": "SpreeStripe::PayoutProvider", "preferred_default_payouts_schedule_interval": "weekly", "preferred_default_minimum_payout_amount": 50}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Then add the **Payout account** row to your [seller requirements](/user/sellers/onboarding) so a seller cannot be approved before Stripe can pay them. Without it a seller is approved, sells, and their earnings sit pending until they connect an account.
|
|
42
|
+
|
|
43
|
+
## Seller onboarding
|
|
44
|
+
|
|
45
|
+
A seller connects from their panel. The panel asks Spree for a link, Spree creates an Express account for the seller if they have none and asks Stripe for a hosted onboarding link, and the seller lands on Stripe's own pages to give identity and bank details. Spree never handles either.
|
|
46
|
+
|
|
47
|
+
The account is created with the platform paying Stripe's fees and owning disputes (what makes this a marketplace), the transfers capability requested, the Express dashboard, and — deliberately — a **manual payout schedule**. Spree decides when a seller is settled, so Stripe must not also be paying their balance out on a clock of its own.
|
|
48
|
+
|
|
49
|
+
The seller's account id is stored as an external reference on the seller under the `stripe` system, so a marketplace that later changes providers keeps it on record.
|
|
50
|
+
|
|
51
|
+
Three things are worth knowing about "done":
|
|
52
|
+
|
|
53
|
+
- **Stripe decides, not the form.** A seller who finished typing may still be under review for days. The onboarding checklist asks Stripe each time it is rendered, and shows the seller one of three states: something to do, being checked, or refused — with Stripe's own reason where it gives one.
|
|
54
|
+
- **`account.updated` is the signal.** When Stripe flips `payouts_enabled`, the webhook stamps the seller payable and immediately sends any earnings that were waiting. It can also flip back — expired documents, a closed account — and Spree then stops crediting the seller rather than promise money nothing can send.
|
|
55
|
+
- **The stamp is a cache.** If the webhook never arrived (a local tunnel that was down, an endpoint not yet registered), the next checklist render asks Stripe directly and corrects it.
|
|
56
|
+
|
|
57
|
+
## Webhooks
|
|
58
|
+
|
|
59
|
+
Connect events arrive on a separate endpoint from your payment events, because Stripe scopes them separately: events raised inside sellers' connected accounts come on their own subscription with their own signing secret. Spree registers it with Stripe the first time a seller asks for an onboarding link, at:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
https://your-store.com/api/v3/webhooks/payouts/<payment method id>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
subscribed to `account.updated`, `payout.paid` and `payout.failed`. The endpoint id and signing secret are stored on the payment method. Nothing to do in production.
|
|
66
|
+
|
|
67
|
+
For local development Stripe cannot reach your machine, so forward the connected-account events with the Stripe CLI and give Spree the secret it prints:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
stripe listen --forward-connect-to localhost:3000/api/v3/webhooks/payouts/<payment method id>
|
|
71
|
+
export STRIPE_CONNECT_SIGNING_SECRET=whsec_...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`STRIPE_SIGNING_SECRET` covers the payments endpoint the same way; the two are different secrets.
|
|
75
|
+
|
|
76
|
+
## How the money moves
|
|
77
|
+
|
|
78
|
+
**On fulfillment**, Spree records the seller's earning — their sale less commission — and creates a Stripe transfer to their connected account. The transfer names the customer's charge as its `source_transaction`, so it settles when that charge does rather than out of your platform balance: a marketplace should not have to float its sellers. The order number goes on the transfer group, and the ledger row's id on the metadata, so both sides reconcile. A seller who is not payable yet keeps the earning pending; it is sent when they become payable, or on the hourly retry.
|
|
79
|
+
|
|
80
|
+
**On the schedule**, a sweep batches the seller's confirmed earnings into a payout and asks Stripe — acting as the connected account — to pay the seller's bank. The payout stays **pending** in Spree with Stripe's `po_…` id as its reference until `payout.paid` arrives, which completes it and debits the seller's balance. `payout.failed` marks it failed and releases the earnings for the next sweep.
|
|
81
|
+
|
|
82
|
+
**On a refund**, Spree writes a reversal row against the seller so the books are right. The Stripe transfer itself is not reversed in open source, and a negative balance is not netted against future earnings — that money operation is Spree Enterprise.
|
|
83
|
+
|
|
84
|
+
Every Stripe call carries an idempotency key derived from the ledger row, so a retried request finds the movement it already made. When Stripe's answer is lost — a timeout, a 5xx — Spree parks the row as **unresolved** instead of retrying, because the key only protects a retry for as long as Stripe remembers it. Resolve those against the Stripe Dashboard: mark the payout complete with its reference if the money went, or fail it if it did not.
|
|
85
|
+
|
|
86
|
+
## What sellers see
|
|
87
|
+
|
|
88
|
+
Their panel shows earnings as transfers, settlements as payouts, and a balance per currency — read-only, since Spree and Stripe write both sides. For the bank side (what Stripe actually paid out, and to which account) they use the Express dashboard Stripe gives every connected account.
|
|
89
|
+
|
|
90
|
+
## Test mode
|
|
91
|
+
|
|
92
|
+
Use test keys on the Stripe payment method and everything above runs against Stripe's test environment, including Express onboarding with Stripe's test identity data and simulated `payout.paid` events. Connect must be enabled on the test account too.
|
|
93
|
+
|
|
94
|
+
## Related
|
|
95
|
+
|
|
96
|
+
- [Payouts](/user/sellers/payouts) — the operator's view: balances, the queue, marking payouts complete
|
|
97
|
+
- [Seller payouts](../../developer/providers/payouts.md) — the provider contract, if Stripe is not your rails
|
|
98
|
+
- [Stripe payments](stripe.md) — the payment method Connect builds on
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Connect Stripe payments to Spree Commerce"
|
|
3
3
|
sidebarTitle: Stripe
|
|
4
|
-
description: "Set up Spree's native Stripe integration to accept cards and alternative payment methods,
|
|
4
|
+
description: "Set up Spree's native Stripe integration to accept cards and alternative payment methods, and pay marketplace sellers through Stripe Connect."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Stripe is a widely used payment processor that enables secure and seamless transactions for online businesses. It supports multiple payment methods, automates payouts, and ensures a smooth checkout experience for customers.
|
|
8
8
|
|
|
9
|
-
> **TIP:**
|
|
9
|
+
> **TIP:** Running a marketplace? The same Stripe account pays your sellers through [Stripe Connect](stripe-connect.md).
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
@@ -119,13 +119,4 @@ See [<u>Settings → Tax</u>](/user/settings/tax) for more on configuring tax ra
|
|
|
119
119
|
|
|
120
120
|
## Stripe Connect for Marketplaces
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- Automated split payments between marketplace and vendors
|
|
125
|
-
- Support for multiple payment methods including cards and digital wallets
|
|
126
|
-
- Configurable marketplace fees and commission structures
|
|
127
|
-
- Automated payouts to vendors
|
|
128
|
-
- Comprehensive transaction reporting
|
|
129
|
-
- Built-in fraud prevention tools
|
|
130
|
-
|
|
131
|
-
Feel free to [reach out](https://spreecommerce.org/get-started/) to learn more.
|
|
122
|
+
Stripe Connect is the payout provider that ships with this integration: each seller onboards to an Express account through Stripe's hosted pages, their share of every order is transferred as it ships, and settlements are confirmed by webhook. Setup and the money flow are on their own page — [Stripe Connect for marketplaces](stripe-connect.md).
|