@mercurjs/core ships the building blocks: the workflows that decide and send payouts, the webhook route and subscriber, and the provider interface. It doesn’t ship a scheduler. The Stripe connector plugin brings one, and without it you call the workflows yourself.
What ships in @mercurjs/core
The core plugin gives you every piece that talks to the provider or writes payout records:
- Account workflows:
createPayoutAccountWorkflow,createOrRefreshSellerPayoutAccountWorkflow,createOnboardingWorkflow, andsyncPayoutAccountWorkflow - Payout workflows:
createOrderPayoutsWorkflow,createPayoutReversalsWorkflow,processPayoutWorkflow, andprocessPayoutReversalWorkflow - Webhook route:
POST /hooks/payoutkeeps the raw body and emitspayout.webhook_receivedwith a 5000 ms delay and 3 attempts - Webhook subscriber:
payout-webhookparses the event through the provider and runsprocessPayoutForWebhookWorkflow - Admin and vendor routes: payout accounts (
sync,refresh-link, map an existing provider account) and payouts (list, detail,POST /admin/payouts/:id/retry) - Providers: the built-in
systemprovider, plus@mercurjs/stripe-connector-pluginas a separate package
What decides when payouts run
No scheduled job or subscriber in core callscreateOrderPayoutsWorkflow or createPayoutReversalsWorkflow. Core has no jobs directory.
The Stripe connector plugin owns the schedule. It ships seven jobs, payout-sync-onboarding, payout-validate-captures, payout-process-transfers, payout-process-reversals, payout-process-settlements, payout-alert-failed-operations, and payout-monitor-kyc, plus subscribers that run a payout pass on fulfillment, cancellation, payment capture, and refunds. It also owns settlements, operator adjustments, job runs, checkpoints, and the operator notification log, in its own stripe_connector module. See Stripe Connect.
If you build on another provider, schedule the workflows from your own job or subscriber.
How createOrderPayoutsWorkflow decides
createOrderPayoutsWorkflow takes order_ids and an optional required_fulfillment_status (default fulfilled). For each order it loads the total, the fulfillments, the seller’s payout account, the payment, and the commission lines, then decides:
When every check passes, the payout is
pending and the workflow sends it to the provider in the same run. The amount is the order total minus its commission lines. For an order with a total of 47.00 and 4.70 in commission lines, the payout amount is 42.30.
A refund that lands before the payout is deducted from it, with its share of the commission. The reversal pass then skips that refund.
Each order gets at most one payout, because order_id is unique on payout. Running the workflow again re-decides payouts that are on_hold or failed and leaves paid, processing, and aborted ones alone, so it’s safe to call on a timer or from several subscribers.
How reversals work
createPayoutReversalsWorkflow takes order_ids. For every refund on an order whose payout exists, it writes one payout_reversal, the way Medusa keeps a Refund under a Payment:
- The amount is the refund minus its share of the commission:
refund − commission × refund / order total - The reversal waits
on_holduntil the payout ispaid - A refund already deducted from the payout is skipped
reversePayout. Running it again leaves reversals that are already paid alone.
Trigger payouts from your own code
Without the Stripe connector plugin, decide when an order is ready yourself. This subscriber runs a payout pass when Medusa emitsorder.fulfillment_created:
src/subscribers/payout-on-fulfillment.ts
on_hold payout that the next pass re-decides. A payment captured after fulfillment is picked up the same way, so pair the subscriber with a scheduled job that retries on_hold and failed payouts.
The provider interface
Every external call goes throughIPayoutProvider from @mercurjs/types. The module loads exactly one provider and throws Payout module should be initialized with exactly one provider otherwise.
Write a provider as a class that implements
IPayoutProvider and has a static identifier; the module registers it under pop_<identifier>_<id>. Throw a PayoutProviderError from createPayout or reversePayout to tell the module what happened:
{ code: PayoutProviderErrorCode.REFUSED }when the provider rejected the request and created nothing, such as a validation or balance error. The row isfailedand the retry gets a new idempotency key.{ code: PayoutProviderErrorCode.FINAL }(or{ final: true }) when a retry can’t succeed. The row isaborted.- Anything else, including a plain
Errorfrom a timeout, counts as an unknown outcome. The row isfailedand the retry reuses the idempotency key, so a payout the provider did make isn’t made twice.
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.
Related
- Account lifecycle for the statuses the webhooks set
- Workflows reference for inputs, steps, and the
payoutsCreatedhook - Order commission lines for where the commission sum comes from