Skip to Content
MarketplaceFund Management

Fund Management

Money is the lifeblood of any marketplace, and handling it correctly is critical. This page explains how funds flow from buyer payment through to seller payout, including the hold periods, fee deductions, and safety mechanisms we use.

The Money Journey

When a buyer pays for an order, the money doesn’t go directly to the seller. Instead, it follows a controlled path:

This approach protects everyone:

  • Buyers can get refunds if something goes wrong
  • Sellers eventually get paid for legitimate sales
  • Platform can mediate disputes and detect fraud before funds leave the platform

Tracking Funds on Orders

Each order has a funds object that tracks exactly what’s happened to the money:

interface OrderFunds { // Concurrency control operationLock: 'none' | 'refund' | 'transfer' // Amounts (in cents) charged: number // Original amount held refunded: number // Total refunded to buyer transferred: number // Total transferred to seller // Stripe references transferId?: string refundIds?: string[] // Release timing payoutEligibleAt?: Date // When funds CAN be released paidOutAt?: Date // When funds WERE released // Distribution breakdown distribution?: { sellerFee: number // Platform fee deducted from seller buyerFee: number // Buyer protection fee tax: number // Tax withheld for remittance couponDiscount: number // Platform-absorbed discount } }

All amounts are in cents (or the equivalent minor unit for other currencies). A $10.50 order has charged: 1050.

Fund States

Funds move through different states during the order lifecycle:

When an order is created, the full amount is held in Stripe. Nothing has been refunded or transferred yet.

charged: 5000 refunded: 0 transferred: 0

Settlement Delay

Funds are not released to the seller immediately when an order reaches a terminal status. There is a mandatory 3-day settlement delay after settlement before funds are transferred.

When an order reaches a terminal status (completed, resolved_partial_refund, resolved_no_refund), the payoutEligibleAt field is set to 3 days in the future. The background job will only pick up orders for fund release once this date has passed.

This delay exists because we use platform-funded transfers (to support coupons and discounts where the transfer amount may differ from the charge amount). Unlike source-transaction-linked transfers, platform-funded transfers make funds immediately available to the seller — so the settlement delay gives us a window to:

  • Detect fraud patterns (e.g., self-dealing between buyer and seller accounts)
  • Handle chargebacks that arrive shortly after order completion
  • Intervene on suspicious coupon/discount abuse

Timeline Summary

EventTiming
Order deliveredpayoutEligibleAt set to delivery + 7 days (auto-complete window)
Order completed (buyer confirms or auto-complete)payoutEligibleAt overwritten to settlement + 3 days
Dispute resolved (partial/no refund)payoutEligibleAt set to resolution + 3 days
Background job transfers fundsAfter payoutEligibleAt has passed

How Fund Release Works

When an order is ready for fund release, here’s what happens:

Wait for Settlement Delay

Order must be in a “fund-releasable” status AND payoutEligibleAt must be in the past

Check Eligibility

Order must be in a fund-releasable status (completed, resolved_partial_refund, resolved_no_refund)

Acquire Lock

Set operationLock = "transfer" to prevent concurrent operations

Calculate Release Amount

Determine how much the seller actually receives

Create Stripe Transfer

Send money to seller’s Stripe Connect account (platform-funded transfer)

Update Records

Store the transfer ID and mark funds as released

Release Lock

Set operationLock = "none"

Calculating the Release Amount

The seller doesn’t get the full order amount — we deduct fees, tax, and any refunds:

releaseAmount = charged - sellerFee - buyerFee - remainingTax - refunded

For NA-region orders, charged includes sales tax collected by Stripe Tax. Tax is withheld from the seller’s payout and stays on the platform for remittance. See US Tax Compliance for details.

Example: Normal Order

Order total: $100.00 (subtotal: $90, shipping: $10) Seller fee (8%): - $8.00 Refunds: - $0.00 ───────────────────────── Release amount: $92.00

Example: Partial Refund Dispute

Order total: $100.00 (subtotal: $90, shipping: $10) Seller fee (8%): - $8.00 Partial refund: - $20.00 ───────────────────────── Release amount: $72.00

The seller fee is calculated on the original order total, not on the post-refund amount. So even with a partial refund, the fee is still based on the full $100.

Platform-Funded Transfers

We use Stripe’s platform-funded transfer model — transfers draw from the platform’s available Stripe balance rather than being linked to a specific charge via source_transaction.

const transfer = await stripe.transfers.create({ amount: releaseAmountCents, currency: order.currency, destination: sellerStripeAccountId, // No source_transaction — platform-funded transfer_group: transaction.id, metadata: { orderId: order.id, transactionId: transaction.id, } })

Why platform-funded transfers? We support coupons and promotional discounts, which means the seller payout can differ from the buyer’s charge amount. With source_transaction, the transfer is capped at the charge amount. Platform-funded transfers have no such limitation.

Trade-off: Platform-funded transfers make funds immediately available to the connected account (the seller). This is why the settlement delay is critical — it prevents instant fund extraction in fraud scenarios.

When Can Funds Be Released?

Only certain order statuses allow fund release, and the settlement delay must have passed:

StatusCan release?Why
completedAfter delayNormal successful order
resolved_partial_refundAfter delayDispute resolved, seller gets remainder
resolved_no_refundAfter delayDispute resolved in seller’s favor
cancelled_*NoFull refund to buyer
resolved_full_refundNoFull refund to buyer
pending_*, shipped, deliveredNoOrder not finished
dispute_*NoMust resolve dispute first

The Background Release Job

We run a scheduled job (every 5 minutes via EventBridge) that automatically releases funds when orders are ready:

The job handles:

  1. Refunds for cancelled or fully-refunded orders
  2. Fund releases for completed/resolved orders where payoutEligibleAt has passed

Fund release is never triggered synchronously from API handlers. When a buyer confirms receipt or a dispute is resolved, only the status transition happens immediately. The actual Stripe transfer is always deferred to the background job after the settlement delay.

Concurrency Control

What if two processes try to release funds (or refund) at the same time? We use a Compare-And-Swap (CAS) lock:

// Try to acquire lock const result = await OrderModel.findOneAndUpdate( { _id: orderId, 'funds.operationLock': 'none' // Only if no operation running }, { $set: { 'funds.operationLock': 'transfer' } } ) if (!result) { throw new Error('Another operation is in progress') } // ... do the Stripe operation ... // Release lock when done await OrderModel.updateOne( { _id: orderId }, { $set: { 'funds.operationLock': 'none' } } )

Always release the lock, even if the Stripe operation fails. If we don’t, the order becomes “stuck” and can’t be processed.

Stripe is the Source of Truth

We follow a “Stripe-first” pattern for all financial operations:

Attempt Stripe Operation

Try the refund or transfer in Stripe first

If Stripe Succeeds

Update our database to match

If Stripe Fails

Don’t update the database — our records should match Stripe’s reality

This ensures our database never says money moved when it didn’t. Stripe is always the authoritative source.

Webhook Backup

Stripe sends webhooks for financial events as a backup sync mechanism:

EventWhat we do
charge.refundedVerify our refund records match
transfer.createdConfirm transfer was recorded
payout.paidTrack when seller actually received funds

These are mostly for monitoring — we don’t rely on them for primary operations.

Failure Scenarios

FailureHandling
Stripe API timeoutRelease lock, return error, retry later
Stripe rejects transferRelease lock, log error, alert ops
Database error after Stripe successCritical: Log for manual reconciliation
Lock stuck (process crashed)Job detects old locks and clears them

Monitoring

Key things to watch:

MetricAlert if
Pending releasesOrders eligible but not released for >24h after payoutEligibleAt
Failed transfersAny transfer failures
Stuck locksoperationLock != 'none' for >1h
Amount mismatchesDB and Stripe amounts don’t match

Code Locations

ComponentLocation
Fund Release Servicepackages/backend/features/orders/src/fund-release.service.ts
Refund Servicepackages/backend/features/orders/src/refund.service.ts
Order Rules (delay constant)packages/backend/domains/order/src/order.rules.ts
Order Transitionspackages/backend/domains/order/src/order.transitions.ts
Background Job Handlerpackages/backend/features/orders/src/stuck-fund-operations.handler.ts
Order Query Servicepackages/backend/features/orders/src/order-query.service.ts
Order Funds Schemapackages/core/db/src/mongo/marketplace/order.model.ts
Last updated on