@mercurjs/docs 2.3.8-canary.3 → 2.3.9-canary.1

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.
Files changed (120) hide show
  1. package/content/learn/architecture.mdx +166 -263
  2. package/content/learn/introduction.mdx +9 -1
  3. package/content/learn/use-cases.mdx +70 -0
  4. package/content/platform/attribute/concepts/attribute-types.mdx +73 -46
  5. package/content/platform/attribute/concepts/global-vs-inline.mdx +54 -32
  6. package/content/platform/attribute/concepts/variant-axes.mdx +54 -32
  7. package/content/platform/attribute/guides/attach-attributes-to-a-product.mdx +96 -53
  8. package/content/platform/attribute/guides/create-a-variant-axis.mdx +106 -57
  9. package/content/platform/attribute/guides/create-an-attribute.mdx +105 -56
  10. package/content/platform/attribute/overview.mdx +37 -33
  11. package/content/platform/attribute/reference/data-models.mdx +53 -38
  12. package/content/platform/attribute/reference/events.mdx +47 -24
  13. package/content/platform/attribute/reference/links.mdx +98 -27
  14. package/content/platform/attribute/reference/service.mdx +71 -32
  15. package/content/platform/attribute/reference/workflows.mdx +158 -28
  16. package/content/platform/catalog/concepts/master-products.mdx +39 -37
  17. package/content/platform/catalog/concepts/product-seller-allowlist.mdx +56 -35
  18. package/content/platform/catalog/concepts/status-lifecycle.mdx +55 -49
  19. package/content/platform/catalog/concepts/variants-categories-collections.mdx +55 -35
  20. package/content/platform/catalog/guides/allowlist-stores.mdx +100 -37
  21. package/content/platform/catalog/guides/create-a-master-product.mdx +124 -48
  22. package/content/platform/catalog/guides/publish-or-reject-a-product.mdx +120 -55
  23. package/content/platform/catalog/overview.mdx +39 -35
  24. package/content/platform/catalog/reference/data-models.mdx +154 -43
  25. package/content/platform/catalog/reference/events.mdx +41 -25
  26. package/content/platform/catalog/reference/links.mdx +196 -28
  27. package/content/platform/catalog/reference/service.mdx +57 -35
  28. package/content/platform/catalog/reference/workflows.mdx +124 -30
  29. package/content/platform/commission/concepts/order-commission-lines.mdx +54 -52
  30. package/content/platform/commission/concepts/rule-matching.mdx +53 -63
  31. package/content/platform/commission/concepts/rules-and-rates.mdx +61 -59
  32. package/content/platform/commission/guides/batch-update-rules.mdx +69 -41
  33. package/content/platform/commission/guides/create-a-rate.mdx +101 -53
  34. package/content/platform/commission/guides/refresh-order-commission-lines.mdx +104 -35
  35. package/content/platform/commission/overview.mdx +43 -35
  36. package/content/platform/commission/reference/data-models.mdx +59 -45
  37. package/content/platform/commission/reference/events.mdx +28 -34
  38. package/content/platform/commission/reference/links.mdx +42 -33
  39. package/content/platform/commission/reference/service.mdx +103 -28
  40. package/content/platform/commission/reference/workflows.mdx +101 -27
  41. package/content/platform/offer/concepts/pricing-and-inventory.mdx +63 -49
  42. package/content/platform/offer/concepts/shipping.mdx +28 -29
  43. package/content/platform/offer/concepts/what-is-an-offer.mdx +48 -45
  44. package/content/platform/offer/guides/bulk-create-offers.mdx +97 -61
  45. package/content/platform/offer/guides/create-an-offer.mdx +112 -60
  46. package/content/platform/offer/guides/manage-offer-inventory.mdx +78 -38
  47. package/content/platform/offer/overview.mdx +39 -32
  48. package/content/platform/offer/reference/data-models.mdx +77 -47
  49. package/content/platform/offer/reference/events.mdx +44 -18
  50. package/content/platform/offer/reference/links.mdx +92 -40
  51. package/content/platform/offer/reference/service.mdx +66 -31
  52. package/content/platform/offer/reference/workflows.mdx +146 -37
  53. package/content/platform/order-group/concepts/computed-totals.mdx +40 -37
  54. package/content/platform/order-group/concepts/order-splitting.mdx +41 -39
  55. package/content/platform/order-group/concepts/the-order-group.mdx +39 -32
  56. package/content/platform/order-group/guides/list-order-groups.mdx +96 -56
  57. package/content/platform/order-group/guides/retrieve-an-order-group.mdx +93 -42
  58. package/content/platform/order-group/guides/split-a-cart.mdx +115 -40
  59. package/content/platform/order-group/overview.mdx +42 -32
  60. package/content/platform/order-group/reference/data-models.mdx +48 -31
  61. package/content/platform/order-group/reference/events.mdx +29 -17
  62. package/content/platform/order-group/reference/links.mdx +90 -29
  63. package/content/platform/order-group/reference/service.mdx +55 -31
  64. package/content/platform/order-group/reference/workflows.mdx +83 -24
  65. package/content/platform/payout/concepts/account-lifecycle.mdx +64 -67
  66. package/content/platform/payout/concepts/accounts-and-onboarding.mdx +50 -40
  67. package/content/platform/payout/concepts/payout-pipeline.mdx +70 -73
  68. package/content/platform/payout/guides/create-a-payout-account.mdx +65 -41
  69. package/content/platform/payout/guides/process-a-provider-webhook.mdx +84 -38
  70. package/content/platform/payout/guides/start-provider-onboarding.mdx +90 -46
  71. package/content/platform/payout/overview.mdx +43 -34
  72. package/content/platform/payout/reference/data-models.mdx +63 -36
  73. package/content/platform/payout/reference/events.mdx +56 -30
  74. package/content/platform/payout/reference/links.mdx +52 -22
  75. package/content/platform/payout/reference/service.mdx +72 -33
  76. package/content/platform/payout/reference/workflows.mdx +80 -19
  77. package/content/platform/product-edit/concepts/change-actions.mdx +52 -45
  78. package/content/platform/product-edit/concepts/change-pipeline.mdx +60 -52
  79. package/content/platform/product-edit/concepts/status-and-auto-confirm.mdx +43 -46
  80. package/content/platform/product-edit/guides/confirm-or-decline-a-change.mdx +104 -56
  81. package/content/platform/product-edit/guides/edit-a-product.mdx +126 -46
  82. package/content/platform/product-edit/guides/request-a-revision.mdx +99 -39
  83. package/content/platform/product-edit/overview.mdx +41 -34
  84. package/content/platform/product-edit/reference/data-models.mdx +73 -47
  85. package/content/platform/product-edit/reference/events.mdx +39 -29
  86. package/content/platform/product-edit/reference/links.mdx +32 -24
  87. package/content/platform/product-edit/reference/service.mdx +45 -26
  88. package/content/platform/product-edit/reference/workflows.mdx +173 -31
  89. package/content/platform/review/concepts/product-vs-seller-reviews.mdx +42 -41
  90. package/content/platform/review/concepts/ratings-and-moderation.mdx +45 -44
  91. package/content/platform/review/concepts/the-review-model.mdx +52 -45
  92. package/content/platform/review/guides/compute-aggregate-ratings.mdx +62 -37
  93. package/content/platform/review/guides/create-a-review.mdx +109 -39
  94. package/content/platform/review/guides/moderate-a-review.mdx +83 -37
  95. package/content/platform/review/guides/respond-to-a-review.mdx +85 -40
  96. package/content/platform/review/overview.mdx +43 -33
  97. package/content/platform/review/reference/data-models.mdx +41 -27
  98. package/content/platform/review/reference/events.mdx +55 -37
  99. package/content/platform/review/reference/links.mdx +80 -22
  100. package/content/platform/review/reference/service.mdx +61 -33
  101. package/content/platform/review/reference/workflows.mdx +80 -24
  102. package/content/platform/store/concepts/lifecycle.mdx +68 -44
  103. package/content/platform/store/concepts/store-entity.mdx +45 -27
  104. package/content/platform/store/concepts/team.mdx +56 -35
  105. package/content/platform/store/guides/create-a-store.mdx +102 -35
  106. package/content/platform/store/guides/manage-the-team.mdx +122 -32
  107. package/content/platform/store/guides/moderate-a-store.mdx +107 -38
  108. package/content/platform/store/overview.mdx +41 -34
  109. package/content/platform/store/reference/data-models.mdx +132 -57
  110. package/content/platform/store/reference/events.mdx +42 -17
  111. package/content/platform/store/reference/links.mdx +67 -49
  112. package/content/platform/store/reference/service.mdx +105 -33
  113. package/content/platform/store/reference/workflows.mdx +304 -24
  114. package/content/references/api/conventions.mdx +20 -0
  115. package/content/resources/integrations/magento2.mdx +162 -0
  116. package/content/resources/integrations/overview.mdx +10 -0
  117. package/content/resources/integrations/stripe-connect.mdx +1 -1
  118. package/content/resources/tutorials/custom-api-route.mdx +3 -2
  119. package/llms.txt +113 -111
  120. package/package.json +1 -1
@@ -1,99 +1,96 @@
1
1
  ---
2
- title: "The payout pipeline"
2
+ title: "How a payout reaches a seller"
3
3
  sidebarTitle: "The payout pipeline"
4
- description: "Capture check, payment capture, daily payout, transfer, and the provider interface."
4
+ description: "What Mercur ships for payouts, what you trigger yourself, and how the provider interface fits in."
5
5
  ---
6
6
 
7
- In this document, you'll learn how an authorized payment becomes a transfer to a
8
- seller, and how the provider interface fits in.
7
+ The payout pipeline is the path from a placed order to money in a seller’s account. Mercur ships the building blocks: the workflows, the webhook route and subscriber, and the provider interface. It doesn’t ship the scheduler that decides when an order is paid out. You choose that moment and call `createPayoutWorkflow` yourself.
9
8
 
10
- ## Payout
9
+ ## What ships in `@mercurjs/core`
11
10
 
12
- A payout is a single transfer of a seller's earnings for one order. It is
13
- represented by the `Payout` data model (table `payout`, id prefix `pout`). Its
14
- `amount` is the order total minus the order's commission lines, and it belongs to
15
- the seller's `PayoutAccount`.
11
+ The core plugin gives you every piece that talks to the provider or writes payout records:
16
12
 
17
- ```ts
18
- // createPayoutWorkflow computes the seller's share
19
- const amount = MathBN.sub(order.total, totalCommission)
20
- ```
13
+ - **Workflows**: `createPayoutAccountWorkflow`, `createOnboardingWorkflow`, `createPayoutWorkflow`, and `processPayoutForWebhookWorkflow`
14
+ - **Webhook route**: `POST /hooks/payout` keeps the raw body and emits `payout.webhook_received` with a 5000 ms delay and 3 attempts
15
+ - **Webhook subscriber**: `payout-webhook` parses the event through the provider and runs `processPayoutForWebhookWorkflow`
16
+ - **Providers**: the built-in `system` provider, plus `@mercurjs/payout-stripe-connect` as a separate package
21
17
 
22
- The flow is designed to run automatically. Scheduled jobs and event-driven
23
- subscribers move each order from authorized payment to settled transfer with no
24
- manual step.
18
+ ## What you wire yourself
25
19
 
26
- <Note>
27
- The payout **workflows** (`createPayoutWorkflow`, `createPayoutAccountWorkflow`,
28
- `createOnboardingWorkflow`, `processPayoutForWebhookWorkflow`) and the provider
29
- **webhook subscriber** ship in `@mercurjs/core`. The **scheduled jobs** that
30
- drive capture and daily payout are wired up in your project (under
31
- `apps/api/src/jobs`), along with the `order.capture_requested` and
32
- `payout.requested` events they emit. The steps below describe that intended
33
- pipeline and its integration points, not jobs bundled in the core plugin.
34
- </Note>
20
+ No scheduled job, subscriber, or route in core calls `createPayoutWorkflow`. Core also has no `jobs` directory, so nothing captures authorized payments on a timer.
35
21
 
36
- ## 1. Capture check (every 15 min)
22
+ `@mercurjs/types` exports a `PayoutEvents` enum with `order.capture_requested`, `order.authorization_expired`, and `payout.requested`, but nothing emits these events or subscribes to them. The enum carries a TODO to move them to order events. Treat the names as reserved, not as hooks you can listen to.
37
23
 
38
- A scheduled job scans for orders ready for capture. An order qualifies when its
39
- payment is `authorized`, the seller has an `ACTIVE` payout account, the order
40
- meets the required fulfillment status (default `fulfilled`), and no payout
41
- exists yet. As the capture deadline nears (authorization window minus safety
42
- buffer), it emits `order.capture_requested`. If the authorization already
43
- expired, it emits `order.authorization_expired`.
24
+ The module options also describe a capture and payout schedule. `PayoutModuleOptions` and `PAYOUT_MODULE_OPTION_DEFAULTS` define them, and `getOptions()` returns them with defaults applied, but no core code reads them:
44
25
 
45
- ## 2. Payment capture (event-driven)
26
+ | Option | Default | Intended meaning |
27
+ | --- | --- | --- |
28
+ | `disabled` | `false` | Turn off capture checks and daily payouts |
29
+ | `authorizationWindowMs` | `604800000` (7 days) | How long an authorization stays valid |
30
+ | `sellerActionWindowMs` | `259200000` (72 hours) | How long a seller has to act on an order |
31
+ | `captureSafetyBufferMs` | `86400000` (24 hours) | Margin before authorization expiry |
32
+ | `requiredFulfillmentStatus` | `"fulfilled"` | Minimum fulfillment status for a payout |
46
33
 
47
- A subscriber listens for `order.capture_requested` and runs Medusa's
48
- `capturePaymentWorkflow` to capture the authorized payment. On success, the order
49
- is marked captured. On failure, it's flagged so it isn't retried.
34
+ <Tip>
35
+ Read these options in your own job with `container.resolve(MercurModules.PAYOUT).getOptions()`. That keeps your schedule configurable from `medusa-config.ts`.
36
+ </Tip>
50
37
 
51
- ## 3. Daily payout (1 AM UTC)
38
+ ## How createPayoutWorkflow computes the amount
52
39
 
53
- A daily job scans captured orders that haven't been paid out and emits
54
- `payout.requested` for each one. An order qualifies when its payment is captured,
55
- no payout exists yet, and the seller's account is `ACTIVE`.
40
+ `createPayoutWorkflow` takes one `order_id` and pays the seller’s share of that order. It runs 4 stages:
56
41
 
57
- ## 4. Transfer (event-driven)
42
+ 1. Load the order with `total`, `currency_code`, `seller.payout_account`, items, and shipping methods
43
+ 2. Load every `commission_line` for those items and shipping methods, then sum their `amount`
44
+ 3. Call `createPayoutStep` with `order.total` minus the commission sum, in the order’s currency
45
+ 4. Link the payout to the seller and run the `payoutCreated` hook with `order_id` and `payout_id`
58
46
 
59
- A subscriber listens for `payout.requested` and runs `createPayoutWorkflow`,
60
- which loads the order with its seller, payout account, and commission lines,
61
- computes the seller's share, calls the provider to initiate the transfer, and
62
- creates a `Payout` record linked to the seller.
47
+ For an order with a total of 47.00 and 4.70 in commission lines, the payout amount is 42.30. The workflow throws `Seller does not have a payout account` when the seller has none, and the module throws when the account isn’t `active`.
63
48
 
64
- <Note>
65
- The order id is used as the payout's `idempotency_key`, so a re-emitted
66
- `payout.requested` event never produces a duplicate transfer.
67
- </Note>
49
+ The provider receives the order id as its idempotency key. With Stripe Connect, the order id also becomes the transfer’s `transfer_group`, and the seller id goes into its metadata.
68
50
 
69
- ## The provider interface
51
+ <Warning>
52
+ `createPayoutWorkflow` doesn’t check payment capture, fulfillment status, or earlier payouts for the order. Each run creates a new `Payout` row. Put those checks in the code that triggers it.
53
+ </Warning>
70
54
 
71
- Every external operation goes through the `IPayoutProvider` contract, and the
72
- module registers **exactly one** provider. Stripe Connect ships out of the box.
73
- Any other processor implements the same four methods.
55
+ ## Trigger payouts from your own code
74
56
 
75
- | Method | Purpose |
76
- | --- | --- |
77
- | `createPayoutAccount` | Create the connected account with the provider |
78
- | `createOnboarding` | Produce onboarding data (e.g. a Stripe onboarding link) |
79
- | `createPayout` | Initiate a transfer to the seller |
80
- | `getWebhookActionAndData` | Parse a raw webhook into a `PayoutWebhookResult` |
57
+ You decide when an order is ready. This subscriber pays out when Medusa emits `order.fulfillment_created`:
81
58
 
82
- <Tip>
83
- Provider-specific values, such as account ids, onboarding URLs, or transfer
84
- references, are stored in the `data` JSON fields and never interpreted by the
85
- module. The same code path works for any provider.
86
- </Tip>
59
+ ```ts title="src/subscribers/payout-on-fulfillment.ts"
60
+ import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
61
+ import { createPayoutWorkflow } from "@mercurjs/core/workflows"
87
62
 
88
- ## Configuration
63
+ export default async function payoutOnFulfillment({
64
+ event,
65
+ container,
66
+ }: SubscriberArgs<{ order_id: string }>) {
67
+ await createPayoutWorkflow(container).run({
68
+ input: { order_id: event.data.order_id },
69
+ })
70
+ }
89
71
 
90
- The pipeline's timing is tunable via the payout module options in
91
- `medusa-config.ts`:
72
+ export const config: SubscriberConfig = {
73
+ event: "order.fulfillment_created",
74
+ }
75
+ ```
92
76
 
93
- | Option | Default | Description |
77
+ This fires once per fulfillment, so a partially fulfilled order would be paid more than once. Before you ship it, add a guard that skips orders already paid and orders whose payment isn’t captured. A scheduled job in `src/jobs` that scans orders works the same way.
78
+
79
+ ## The provider interface
80
+
81
+ Every external call goes through `IPayoutProvider` from `@mercurjs/types`. The module loads exactly one provider and throws `Payout module should be initialized with exactly one provider` otherwise.
82
+
83
+ | Method | Called by | Returns |
94
84
  | --- | --- | --- |
95
- | `disabled` | `false` | Disable both scheduled jobs |
96
- | `authorizationWindowMs` | 7 days | How long a payment authorization stays valid |
97
- | `sellerActionWindowMs` | 72 hours | Time a seller has to fulfill before the order is rejected |
98
- | `captureSafetyBufferMs` | 24 hours | Margin before authorization expiry to trigger capture |
99
- | `requiredFulfillmentStatus` | `"fulfilled"` | Minimum fulfillment status before an order is eligible |
85
+ | `createPayoutAccount` | `createPayoutAccount` | Provider `id`, optional `status`, `data` |
86
+ | `createOnboarding` | `createOnboarding` | `data`, such as a Stripe account link |
87
+ | `createPayout` | `createPayouts` | Optional `status`, `data` |
88
+ | `getWebhookActionAndData` | `getWebhookActionAndData` | A `PayoutWebhookResult` |
89
+
90
+ When you register no provider, the module loads `system`. It returns `active` accounts and `paid` payouts without moving money, which suits local development and tests. Register Stripe Connect in `medusa-config.ts` with the [Stripe Connect integration](/resources/integrations/stripe-connect).
91
+
92
+ ## Related
93
+
94
+ - [Account lifecycle](/platform/payout/concepts/account-lifecycle) for the statuses the webhooks set
95
+ - [Workflows reference](/platform/payout/reference/workflows) for the `payoutCreated` hook
96
+ - [Order commission lines](/platform/commission/concepts/order-commission-lines) for where the commission sum comes from
@@ -1,59 +1,83 @@
1
1
  ---
2
- title: "Create a payout account"
2
+ title: "Create a payout account for a seller"
3
3
  sidebarTitle: "Create a payout account"
4
- description: "Create a seller's payout account with createPayoutAccountWorkflow."
4
+ description: "Run createPayoutAccountWorkflow from your own route to connect a seller to the payout provider."
5
5
  ---
6
6
 
7
- In this guide, you'll learn how to create a payout account for a seller from your
8
- own server code. This is useful in an onboarding flow or a custom API route.
7
+ Create a seller’s payout account from server code, for example in an operator route or a custom signup flow. The Vendor API already does this for sellers at `POST /vendor/payout-accounts`.
9
8
 
10
- Mercur exposes a `createPayoutAccountWorkflow` that persists the `PayoutAccount`,
11
- calls the configured provider to create the connected account, and links the
12
- account to the store. Run it from any place that has access to the Medusa
13
- container.
9
+ ## Before you begin
14
10
 
15
- ## Run the workflow
11
+ You need:
16
12
 
17
- ```ts title="src/api/custom/payout-account/route.ts"
18
- import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
19
- import { createPayoutAccountWorkflow } from "@mercurjs/core/workflows"
13
+ - A seller id, such as `sel_01JD8Q1234567`
14
+ - A payout provider. Without one, the `system` provider creates accounts that are `active` at once
15
+ - For Stripe Connect, `@mercurjs/payout-stripe-connect` registered on the payout module, as in the [Stripe Connect integration](/resources/integrations/stripe-connect)
20
16
 
21
- export async function POST(req: MedusaRequest, res: MedusaResponse) {
22
- const { result } = await createPayoutAccountWorkflow(req.scope).run({
23
- input: {
24
- seller_id: req.params.id,
25
- },
26
- })
17
+ ## Create the account
27
18
 
28
- res.status(201).json({ payout_account: result })
29
- }
30
- ```
19
+ <Steps>
20
+ <Step title="Add a route that runs the workflow">
21
+ Create an admin route that takes the seller id from the path and the provider data from the body:
22
+
23
+ ```ts title="src/api/admin/payout-setup/[id]/route.ts"
24
+ import type {
25
+ AuthenticatedMedusaRequest,
26
+ MedusaResponse,
27
+ } from "@medusajs/framework/http"
28
+ import { createPayoutAccountWorkflow } from "@mercurjs/core/workflows"
29
+
30
+ type Body = { country: string }
31
+
32
+ export async function POST(
33
+ req: AuthenticatedMedusaRequest<Body>,
34
+ res: MedusaResponse
35
+ ) {
36
+ const { result } = await createPayoutAccountWorkflow(req.scope).run({
37
+ input: {
38
+ seller_id: req.params.id,
39
+ data: { country: req.body.country },
40
+ },
41
+ })
42
+
43
+ res.status(201).json({ payout_account: { id: result.id } })
44
+ }
45
+ ```
31
46
 
32
- The new account starts in `PENDING` and can't receive payouts until the provider
33
- marks it `ACTIVE`. See [Start onboarding](/platform/payout/guides/start-provider-onboarding).
47
+ The workflow validates the seller has no account, creates it through the provider, and links it to the seller. The response returns only the id, because `result.data` holds the full provider object.
48
+ </Step>
49
+ <Step title="Call the route">
50
+ Send the seller’s country. Stripe Connect requires `country` and throws `"country" is required` without it:
34
51
 
35
- <Note>
36
- A seller can have **only one** payout account. The workflow validates this
37
- first and fails if the store already has one.
38
- </Note>
52
+ ```bash
53
+ curl -X POST http://localhost:9000/admin/payout-setup/sel_01JD8Q1234567 \
54
+ -H "Authorization: Bearer your_admin_token" \
55
+ -H "Content-Type: application/json" \
56
+ -d '{ "country": "US" }'
57
+ ```
39
58
 
40
- ## Forward provider data
59
+ With Stripe Connect, the new account is `pending` and its `data` holds the Express account Stripe created.
60
+ </Step>
61
+ </Steps>
41
62
 
42
- The workflow accepts `data` and `context` payloads that are passed straight to
43
- the provider when it creates the connected account. Use them to hand the
44
- provider anything it needs up front.
63
+ ## Handle a seller that already has an account
64
+
65
+ `validateSellerPayoutAccountStep` runs first and throws a `MedusaError` of type `INVALID_DATA` with the message `Seller already has a payout account`. Medusa returns it as a `400`. To reuse the existing account instead, look it up through the seller link:
45
66
 
46
67
  ```ts
47
- await createPayoutAccountWorkflow(req.scope).run({
48
- input: {
49
- seller_id: "sel_123",
50
- context: { idempotency_key: "sel_123" },
51
- data: { business_type: "company" },
52
- },
68
+ const { data: [seller] } = await query.graph({
69
+ entity: "seller",
70
+ fields: ["id", "payout_account.id", "payout_account.status"],
71
+ filters: { id: "sel_01JD8Q1234567" },
53
72
  })
54
73
  ```
55
74
 
56
- <Warning>
57
- If the provider call fails after the record is created, the workflow rolls the
58
- account back so you don't leave a dangling `PayoutAccount` behind.
59
- </Warning>
75
+ ## What happens when the provider call fails
76
+
77
+ The module creates the `PayoutAccount` row before it calls the provider. If the provider throws, the module deletes that row and rethrows, so no orphaned account is left. The provider receives the new `pacc` id as `data.account_id` and as the default `context.idempotency_key`. Stripe Connect stores that id in the Stripe account’s `metadata.account_id`, which is how webhooks find the account later.
78
+
79
+ ## Next steps
80
+
81
+ - [Start provider onboarding](/platform/payout/guides/start-provider-onboarding) so the seller can finish setup
82
+ - [Account lifecycle](/platform/payout/concepts/account-lifecycle) for how the account becomes `active`
83
+ - [Workflows reference](/platform/payout/reference/workflows) for the steps inside this workflow
@@ -1,38 +1,70 @@
1
1
  ---
2
- title: "Process a provider webhook"
2
+ title: "Process payout provider webhooks"
3
3
  sidebarTitle: "Process a webhook"
4
- description: "Turn a provider webhook into account and payout status updates."
4
+ description: "Route provider webhooks to Mercur so payout accounts and payouts pick up the provider's status changes."
5
5
  ---
6
6
 
7
- In this guide, you'll learn how a provider webhook becomes account and payout
8
- status changes. Mercur already wires this up. A subscriber listens for
9
- `payout.webhook_received` and drives the update workflow. Understanding the path
10
- lets you emit the event yourself or extend the flow.
7
+ Connect your payout provider’s webhooks to Mercur so account and payout statuses follow the provider. You need this before any Stripe Connect account can become `active`.
11
8
 
12
- ## The built-in path
9
+ ## Before you begin
13
10
 
14
- The `payout-webhook` subscriber resolves the raw payload to an action through the
15
- provider, then runs `processPayoutForWebhookWorkflow`:
11
+ You need:
16
12
 
17
- ```ts title="src/subscribers/payout-webhook.ts (shipped)"
18
- const processedEvent = await payoutService.getWebhookActionAndData(input)
13
+ - A payout provider registered on the payout module, such as Stripe Connect
14
+ - For Stripe Connect, a webhook signing secret set as the provider’s `webhookSecret` option
15
+ - The [Stripe CLI](https://docs.stripe.com/stripe-cli) for local testing
19
16
 
20
- if (!processedEvent.data) {
21
- return
22
- }
17
+ ## Receive webhooks
23
18
 
24
- const wfEngine = container.resolve(Modules.WORKFLOW_ENGINE)
25
- await wfEngine.run(processPayoutForWebhookWorkflowId, { input: processedEvent })
26
- ```
19
+ Mercur ships the route, the subscriber, and the workflow. You point the provider at the route and check the result.
20
+
21
+ <Steps>
22
+ <Step title="Point the provider at /hooks/payout">
23
+ In production, add a Stripe webhook endpoint for events on connected accounts, with the URL `https://your_backend_url/hooks/payout` and the `account.updated` event. Locally, forward Connect events with the Stripe CLI:
24
+
25
+ ```bash
26
+ stripe listen --events account.updated \
27
+ --forward-connect-to localhost:9000/hooks/payout
28
+ ```
29
+
30
+ The CLI prints a signing secret. Use it as the provider’s `webhookSecret`.
31
+ </Step>
32
+ <Step title="Let the subscriber run the workflow">
33
+ The route emits `payout.webhook_received` with the body, raw body, and headers. It delays the event by 5000 ms and retries up to 3 times. The shipped `payout-webhook` subscriber then parses it and runs the workflow:
34
+
35
+ ```ts title="src/subscribers/payout-webhook.ts (in @mercurjs/core)"
36
+ const processedEvent = await payoutService.getWebhookActionAndData(input)
37
+
38
+ if (!processedEvent.data) {
39
+ return
40
+ }
41
+
42
+ const wfEngine = container.resolve(Modules.WORKFLOW_ENGINE)
43
+ await wfEngine.run(processPayoutForWebhookWorkflowId, {
44
+ input: processedEvent,
45
+ })
46
+ ```
47
+
48
+ Stripe Connect verifies the `stripe-signature` header against `webhookSecret`. A bad signature throws, and the event bus retries.
49
+ </Step>
50
+ <Step title="Check the account status">
51
+ Read the account to confirm the update landed:
52
+
53
+ ```ts
54
+ const payoutService = container.resolve(MercurModules.PAYOUT)
27
55
 
28
- `getWebhookActionAndData` delegates to the provider, which parses its own payload
29
- and returns a `PayoutWebhookResult`. The result is an `action` plus the affected
30
- `id`.
56
+ const account = await payoutService.retrievePayoutAccount(
57
+ "pacc_01JD8Q1234567"
58
+ )
59
+ ```
60
+
61
+ The `status` is `active`, `restricted`, or `rejected`, depending on what Stripe reported.
62
+ </Step>
63
+ </Steps>
31
64
 
32
65
  ## Run the workflow directly
33
66
 
34
- To process an already-parsed result yourself, run the workflow with a
35
- `PayoutWebhookResult`:
67
+ `processPayoutForWebhookWorkflow` takes a `PayoutWebhookResult`, so you can apply a status change without a webhook, for example in a test or a backfill script:
36
68
 
37
69
  ```ts
38
70
  import { processPayoutForWebhookWorkflow } from "@mercurjs/core/workflows"
@@ -40,35 +72,49 @@ import { processPayoutForWebhookWorkflow } from "@mercurjs/core/workflows"
40
72
  await processPayoutForWebhookWorkflow(container).run({
41
73
  input: {
42
74
  action: "payout.paid",
43
- data: { id: "pout_123" },
75
+ data: { id: "pout_01JD8Q1234567" },
44
76
  },
45
77
  })
46
78
  ```
47
79
 
48
- The workflow branches on `action`, updating the account or the payout:
80
+ The workflow runs at most one step: the one that matches `action`. An `account.*` action needs a `pacc` id, and a `payout.*` action needs a `pout` id. A missing `data.id` or the `not_supported` action changes nothing.
81
+
82
+ ## Map webhooks in your own provider
83
+
84
+ When you write a provider, `getWebhookActionAndData` decides what each webhook means. Return the action and the Mercur id, not the provider’s id:
85
+
86
+ ```ts title="src/providers/acme-payouts/service.ts"
87
+ async getWebhookActionAndData(
88
+ payload: PayoutWebhookActionInput
89
+ ): Promise<PayoutWebhookResult> {
90
+ const body = payload.data as { type: string; reference: string }
91
+
92
+ if (body.type === "transfer.settled") {
93
+ return { action: "payout.paid", data: { id: body.reference } }
94
+ }
49
95
 
50
- | Action | Effect |
51
- | --- | --- |
52
- | `account.activated` / `account.restricted` / `account.rejected` | Set account status to `ACTIVE` / `RESTRICTED` / `REJECTED` |
53
- | `payout.processing` / `payout.paid` / `payout.failed` / `payout.canceled` | Set payout status accordingly |
96
+ return { action: "not_supported" }
97
+ }
98
+ ```
54
99
 
55
- <Note>
56
- Actions the provider can't map return `not_supported` (or a missing `data.id`),
57
- and the workflow makes no change. It is safe to hand it every event the provider
58
- sends.
59
- </Note>
100
+ Verify the provider’s signature here with `payload.rawData` and `payload.headers` before you trust the body. The route keeps the raw body for this purpose.
60
101
 
61
- ## Emit the event yourself
102
+ ## Emit the event from another route
62
103
 
63
- To route a custom provider integration through the same path, emit
64
- `payout.webhook_received` with the raw payload and let the shipped subscriber
65
- take over.
104
+ If your provider must post to a different URL, emit `payout.webhook_received` yourself with the same shape. The shipped subscriber picks it up:
66
105
 
67
106
  ```ts
68
107
  const eventBus = container.resolve(Modules.EVENT_BUS)
69
108
 
70
109
  await eventBus.emit({
71
110
  name: "payout.webhook_received",
72
- data: { rawData, headers, data },
111
+ data: { data: req.body, rawData: req.rawBody, headers: req.headers },
73
112
  })
74
113
  ```
114
+
115
+ Enable `preserveRawBody` on that route’s body parser, or signature checks will fail.
116
+
117
+ ## Next steps
118
+
119
+ - [Account lifecycle](/platform/payout/concepts/account-lifecycle) for every action and the status it sets
120
+ - [Events reference](/platform/payout/reference/events) for the `payout.webhook_received` payload
@@ -1,50 +1,94 @@
1
1
  ---
2
- title: "Start provider onboarding"
2
+ title: "Start provider onboarding for a seller"
3
3
  sidebarTitle: "Start onboarding"
4
- description: "Kick off provider onboarding with createOnboardingWorkflow."
4
+ description: "Run createOnboardingWorkflow to get a provider onboarding link, such as a Stripe Connect account link, for a seller."
5
5
  ---
6
6
 
7
- In this guide, you'll learn how to start provider onboarding for a payout
8
- account from server code. Onboarding is what moves an account from `PENDING`
9
- toward `ACTIVE`. For Stripe Connect, it produces the hosted link the seller uses
10
- to submit their details.
11
-
12
- ## Run the workflow
13
-
14
- `createOnboardingWorkflow` asks the provider to produce onboarding data and
15
- stores it as an `Onboarding` record on the account.
16
-
17
- ```ts title="src/api/custom/onboarding/route.ts"
18
- import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
19
- import { createOnboardingWorkflow } from "@mercurjs/core/workflows"
20
-
21
- export async function POST(req: MedusaRequest, res: MedusaResponse) {
22
- const { result } = await createOnboardingWorkflow(req.scope).run({
23
- input: {
24
- account_id: req.params.id,
25
- context: {
26
- return_url: "https://store.example.com/settings/payouts",
27
- },
28
- },
29
- })
30
-
31
- res.status(201).json({ onboarding: result })
32
- }
33
- ```
34
-
35
- The provider-specific payload (for Stripe Connect, the onboarding URL) is stored
36
- in the record's `data` field for you to return to the seller.
37
-
38
- <Tip>
39
- Running the workflow again on an account that already has an onboarding record
40
- **updates** it in place instead of creating a second one. It is safe to call
41
- whenever a seller needs a fresh link.
42
- </Tip>
43
-
44
- ## Reaching `ACTIVE`
45
-
46
- Onboarding kicks off the flow, but the account only becomes `ACTIVE` when the
47
- provider confirms it via webhook. Handle that step in
48
- [Process a webhook](/platform/payout/guides/process-a-provider-webhook), and see
49
- [Account lifecycle](/platform/payout/concepts/account-lifecycle) for the full
50
- state model.
7
+ Create or refresh a seller’s onboarding link from server code, so they can finish the provider’s setup. The Vendor API already does this at `POST /vendor/payout-accounts/:id/onboarding`.
8
+
9
+ ## Before you begin
10
+
11
+ You need:
12
+
13
+ - A payout account for the seller, created with [Create a payout account](/platform/payout/guides/create-a-payout-account)
14
+ - For Stripe Connect, a `refresh_url` and a `return_url` in your vendor app
15
+
16
+ ## Create the onboarding link
17
+
18
+ <Steps>
19
+ <Step title="Find the seller's payout account">
20
+ Onboarding runs against a `pacc` id, not a seller id. Resolve it through the `seller_payout_account` link:
21
+
22
+ ```ts title="src/api/admin/payout-setup/[id]/onboarding/helpers.ts"
23
+ import type { MedusaContainer } from "@medusajs/framework/types"
24
+ import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
25
+
26
+ export async function getPayoutAccountId(
27
+ container: MedusaContainer,
28
+ sellerId: string
29
+ ) {
30
+ const query = container.resolve(ContainerRegistrationKeys.QUERY)
31
+ const { data: [seller] } = await query.graph({
32
+ entity: "seller",
33
+ fields: ["payout_account.id"],
34
+ filters: { id: sellerId },
35
+ })
36
+ return seller?.payout_account?.id as string | undefined
37
+ }
38
+ ```
39
+ </Step>
40
+ <Step title="Run the workflow from a route">
41
+ Pass the URLs in `data`. The module merges them with the account’s stored `data`, which carries the Stripe account `id`:
42
+
43
+ ```ts title="src/api/admin/payout-setup/[id]/onboarding/route.ts"
44
+ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
45
+ import { MedusaError } from "@medusajs/framework/utils"
46
+ import { createOnboardingWorkflow } from "@mercurjs/core/workflows"
47
+ import { getPayoutAccountId } from "./helpers"
48
+
49
+ export async function POST(req: MedusaRequest, res: MedusaResponse) {
50
+ const accountId = await getPayoutAccountId(req.scope, req.params.id)
51
+ if (!accountId) {
52
+ throw new MedusaError(MedusaError.Types.NOT_FOUND, "No payout account")
53
+ }
54
+
55
+ const { result } = await createOnboardingWorkflow(req.scope).run({
56
+ input: {
57
+ account_id: accountId,
58
+ data: {
59
+ refresh_url: "https://vendor.example.com/payouts/refresh",
60
+ return_url: "https://vendor.example.com/payouts",
61
+ },
62
+ },
63
+ })
64
+
65
+ res.json({ url: result.data?.url })
66
+ }
67
+ ```
68
+
69
+ For Stripe Connect, `result.data` is the Stripe account link, and `url` is the page you redirect the seller to.
70
+ </Step>
71
+ </Steps>
72
+
73
+ ## Refresh an expired link
74
+
75
+ Stripe account links expire. Call the same route again: the module finds the account’s existing `Onboarding` record and updates its `data` instead of creating a second one. Point `refresh_url` at a page in your vendor app that calls this route, so Stripe can send the seller back for a fresh link.
76
+
77
+ ## Required data for Stripe Connect
78
+
79
+ The Stripe Connect provider checks 3 values and throws a `MedusaError` of type `INVALID_DATA` when one is missing:
80
+
81
+ - **`id`**: the Stripe account id, read from the payout account’s stored `data`
82
+ - **`refresh_url`**: where Stripe sends the seller when the link is no longer valid
83
+ - **`return_url`**: where Stripe sends the seller after they leave the flow
84
+
85
+ Put the URLs in `data`, not `context`. The module forwards `context` to the provider, but Stripe Connect reads the URLs only from `data`.
86
+
87
+ ## The account becomes active by webhook
88
+
89
+ Finishing onboarding doesn’t change the account by itself. Stripe sends `account.updated`, the `payout-webhook` subscriber maps it to an action, and `processPayoutForWebhookWorkflow` sets the status.
90
+
91
+ ## Next steps
92
+
93
+ - [Process a provider webhook](/platform/payout/guides/process-a-provider-webhook) to follow the status update
94
+ - [Account lifecycle](/platform/payout/concepts/account-lifecycle) for how Stripe updates map to statuses