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

Release Floor

Funds cannot be released earlier than 3 calendar days from order creation. After that floor, completion releases funds on the next background-job tick (effectively instant).

When an order transitions into a fund-releasable status (completed, resolved_partial_refund, resolved_no_refund, resolved_goodwill_refund), payoutEligibleAt is set to:

payoutEligibleAt = max(now, createdAt + 3 days)
  • Order is ≥ 3 days old at the transition → payoutEligibleAt = now → next job tick releases.
  • Order is < 3 days old → payoutEligibleAt = createdAt + 3 days.

The 3-day floor 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 we keep a short 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

The floor is enforced by computeReleaseEligibleAt in order.rules.ts (FUND_RELEASE_HARD_MINIMUM_DAYS = 3).

Timeline Summary

EventpayoutEligibleAt set to
Order delivered (mark_delivered)deliveredAt + 7 days — this is the buyer’s dispute window, used as the wake-up timer for auto_complete, not as a fund-release delay
Buyer confirms receipt (buyer_confirm_receipt)max(now, createdAt + 3 days) — overwrites the dispute-window timer
Auto-complete fires (auto_complete, after the dispute window)max(now, createdAt + 3 days) — always resolves to now because createdAt + 3 < deliveredAt + 7
Dispute resolved seller-favoured (partial_refund, no_refund, goodwill_refund)max(now, createdAt + 3 days)
Background job transfers fundsOnce payoutEligibleAt <= now

How Fund Release Works

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

Wait Past the Release Floor

Order must be in a fund-releasable status AND payoutEligibleAt must be in the past (i.e. createdAt + 3 days has elapsed)

Check Eligibility

Order must be in a fund-releasable status (completed, resolved_partial_refund, resolved_no_refund, resolved_goodwill_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 release floor is critical — it prevents instant fund extraction in fraud scenarios on freshly-created orders.

When Can Funds Be Released?

Only certain order statuses allow fund release, and payoutEligibleAt must be in the past:

StatusCan release?Why
completedOnce floor passesNormal successful order
resolved_partial_refundOnce floor passesDispute resolved, seller gets remainder
resolved_no_refundOnce floor passesDispute resolved in seller’s favor
resolved_goodwill_refundOnce floor passesPlatform absorbed buyer refund; seller still paid in full
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, which fires once the release floor has passed.

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