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:
Held
When an order is created, the full amount is held in Stripe. Nothing has been refunded or transferred yet.
charged: 5000
refunded: 0
transferred: 0Settlement 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
| Event | Timing |
|---|---|
| Order delivered | payoutEligibleAt 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 funds | After 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 - refundedFor 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.00Example: Partial Refund Dispute
Order total: $100.00 (subtotal: $90, shipping: $10)
Seller fee (8%): - $8.00
Partial refund: - $20.00
─────────────────────────
Release amount: $72.00The 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:
| Status | Can release? | Why |
|---|---|---|
completed | After delay | Normal successful order |
resolved_partial_refund | After delay | Dispute resolved, seller gets remainder |
resolved_no_refund | After delay | Dispute resolved in seller’s favor |
cancelled_* | No | Full refund to buyer |
resolved_full_refund | No | Full refund to buyer |
pending_*, shipped, delivered | No | Order not finished |
dispute_* | No | Must 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:
- Refunds for cancelled or fully-refunded orders
- Fund releases for completed/resolved orders where
payoutEligibleAthas 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:
| Event | What we do |
|---|---|
charge.refunded | Verify our refund records match |
transfer.created | Confirm transfer was recorded |
payout.paid | Track when seller actually received funds |
These are mostly for monitoring — we don’t rely on them for primary operations.
Failure Scenarios
| Failure | Handling |
|---|---|
| Stripe API timeout | Release lock, return error, retry later |
| Stripe rejects transfer | Release lock, log error, alert ops |
| Database error after Stripe success | Critical: Log for manual reconciliation |
| Lock stuck (process crashed) | Job detects old locks and clears them |
Monitoring
Key things to watch:
| Metric | Alert if |
|---|---|
| Pending releases | Orders eligible but not released for >24h after payoutEligibleAt |
| Failed transfers | Any transfer failures |
| Stuck locks | operationLock != 'none' for >1h |
| Amount mismatches | DB and Stripe amounts don’t match |
Code Locations
| Component | Location |
|---|---|
| Fund Release Service | packages/backend/features/orders/src/fund-release.service.ts |
| Refund Service | packages/backend/features/orders/src/refund.service.ts |
| Order Rules (delay constant) | packages/backend/domains/order/src/order.rules.ts |
| Order Transitions | packages/backend/domains/order/src/order.transitions.ts |
| Background Job Handler | packages/backend/features/orders/src/stuck-fund-operations.handler.ts |
| Order Query Service | packages/backend/features/orders/src/order-query.service.ts |
| Order Funds Schema | packages/core/db/src/mongo/marketplace/order.model.ts |