← Files StripeARCHIVED FILE

skills/connect-recommend/references/charge-patterns.md

11.5 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

## Stripe Connect Charge Patterns

### Overview

Connect offers three ways to create charges involving connected accounts. The charge pattern determines who is the merchant of record, how funds flow, and how fees and refunds work.

### Comparison Table

| Feature | Direct Charges | Destination Charges | Separate Charges & Transfers |
| --- | --- | --- | --- |
| **Merchant of record** | Connected account | Platform | Platform |
| **Payment created on** | Connected account | Platform account | Platform account |
| **Statement descriptor** | Connected account’s | Platform’s (can set connected account’s) | Platform’s |
| **Platform fee** | `application_fee_amount` | `application_fee_amount` or calculate using `transfer_data.amount` | Manual calculation |
| **Refund source** | Connected account’s balance | Platform’s balance | Platform’s balance |
| **Multi-seller split** | No (one seller per charge) | No (one destination per charge) | Yes (multiple transfers) |
| **Account requirements** | Most v2 configs — see BLOCKED combinations in the controller compatibility note below; the only charge type safe with `losses_collector: 'stripe'` | Requires `losses_collector: 'application'` | Requires `losses_collector: 'application'` |
| **Complexity** | Low | Low | High |
| **Best for** | SaaS, seller-owned transactions | Marketplaces, on-demand | Multi-seller carts, complex splits |

### Direct Charges

> **Controller Property Compatibility:** Works with most controller configurations, but NOT all. BLOCKED combinations for direct charges include: `fees_collector: 'stripe' + losses_collector: 'application'` (full or none dashboard), and Express dashboard configs other than `application/application`. This is the **only** charge type safe with `losses_collector: 'stripe'`. If the platform wants Stripe to own losses, direct charges are the only option.

#### How it works

The charge is created directly on the connected account. The connected account is the merchant of record — their name appears on the customer’s bank statement. The platform collects an application fee.

#### Code pattern

```javascript
// Backend: Create PaymentIntent on connected account
const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000, // $100.00
  currency: 'usd',
  application_fee_amount: 1500, // $15.00 platform fee
  metadata: {
    orderId: 'order_123',
  },
}, {
  stripeAccount: 'acct_connected_account_id', // Key: stripeAccount header
});

// Return client_secret to frontend
res.json({ clientSecret: paymentIntent.client_secret });
```

#### Frontend (with Stripe.js)

```javascript
// Must initialize Stripe with connected account
const stripe = await loadStripe('pk_test_...', {
  stripeAccount: 'acct_connected_account_id',
});

// Then confirm payment as usual
const result = await stripe.confirmPayment({
  elements,
  confirmParams: {
    return_url: 'https://yoursite.com/success',
  },
});
```

#### Fund flow

```
Customer pays $100
  → $100 lands in connected account's balance
  → $15 application fee transferred to platform
  → Connected account keeps $85
```

#### Refunds

```javascript
// Refund comes from connected account's balance
const refund = await stripe.refunds.create({
  charge: 'ch_xxx',
  // Optionally refund the application fee too:
  refund_application_fee: true,
}, {
  stripeAccount: 'acct_connected_account_id',
});
```

#### When to use

- Direct-charge integrations where sellers own the customer relationship (legacy v1 Standard-style pattern)
- SaaS platforms (Shopify model)
- When the connected account’s name should appear on bank statements
- When sellers handle their own disputes

> **Legacy mapping note (external docs terms):** Stripe docs still reference legacy v1 naming (`standard`, `express`, `custom`) and legacy fee-payer behaviors (`application_express`, `application_custom`) for older accounts. For migration mapping to Accounts v2 dimensions, see the “Legacy migration note” section in the account-types reference.

### Destination Charges

> **Controller Property Compatibility:** REQUIRES `losses_collector: 'application'`. Using destination charges with `losses_collector: 'stripe'` creates a liability-model mismatch for this charge flow. See `compatibility-matrix.md` for details.

#### How it works

The charge is created on the platform’s account. The platform is the merchant of record. Funds are automatically transferred to the connected account using `transfer_data`. This is a common pattern for marketplaces.

#### Code pattern

```javascript
// Backend: Create PaymentIntent on platform account
const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000, // $100.00
  currency: 'usd',
  application_fee_amount: 1500, // $15.00 collected; platform net = $15.00 − Stripe processing fees
  transfer_data: {
    destination: 'acct_connected_account_id', // Funds go here
  },
  metadata: {
    bookingId: 'booking_123',
    riderId: 'user_456',
    operatorId: 'user_789',
  },
});

// Return client_secret to frontend
res.json({ clientSecret: paymentIntent.client_secret });
```

#### Alternative: Specify transfer amount instead of fee

```javascript
const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000, // $100.00
  currency: 'usd',
  transfer_data: {
    destination: 'acct_connected_account_id',
    amount: 8500, // $85.00 goes to connected account (platform keeps $15)
  },
});
```

#### Frontend (standard Stripe.js)

```javascript
// Initialize Stripe with platform's publishable key (no stripeAccount needed)
const stripe = await loadStripe('pk_test_platform_key');

const result = await stripe.confirmPayment({
  elements,
  confirmParams: {
    return_url: 'https://yoursite.com/success',
  },
});
```

#### Fund flow

```
Customer pays $100
  → $100 lands in platform's balance
  → $85 automatically transferred to connected account
  → Platform nets $15 (application_fee_amount) − Stripe processing fees
```

#### Refunds

```javascript
// Refund comes from platform's balance
const refund = await stripe.refunds.create({
  payment_intent: 'pi_xxx',
  // Optionally:
  reverse_transfer: true, // Claw back from connected account
  refund_application_fee: true, // Refund the platform fee too
});
```

#### When to use

- **Marketplaces** where the platform owns the customer relationship
- On-demand platforms (Uber, DoorDash model)
- When you want the platform name on bank statements
- Express dashboard accounts (common pairing)
- When the platform handles disputes
- **NOT for hold-and-release or delivery-gated payouts** — funds transfer automatically to the connected account upon payment success. Use separate charges and transfers for delivery-gated payouts or any scenario requiring the platform to hold funds before releasing.

#### Destination Charges with `on_behalf_of`

> **Not covered by this guide.** `on_behalf_of` is an advanced variant that changes the merchant of record to the connected account while the charge lives on the platform. It has narrow use cases and significant complexity.
> 
> If your integration requires `on_behalf_of`, consult the [Stripe Connect documentation](https://docs.stripe.com/connect/charges.md) or [contact Stripe sales](https://stripe.com/contact/sales).
> 
> **Do NOT use `on_behalf_of` for marketplace use cases** — the platform should be the merchant of record. Use regular destination charges instead.

### Separate Charges and Transfers

> **Controller Property Compatibility:** REQUIRES `losses_collector: 'application'`. Same negative balance liability issue as destination charges — using separate charges and transfers with `losses_collector: 'stripe'` means the platform actually carries the losses despite the configuration. See `compatibility-matrix.md` for details.

#### How it works

The charge and transfer are separate API calls. This gives maximum flexibility — you can split a single payment across multiple connected accounts, delay transfers, or create complex fee structures.

#### Code pattern

```javascript
// Step 1: Create PaymentIntent (no transfer_data)
const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000, // $100.00
  currency: 'usd',
  metadata: {
    orderId: 'order_123',
  },
});

// Step 2: After payment_intent.succeeded webhook fires — latest_charge is null
// at creation time and only populated on the confirmed PaymentIntent from the event
// IMPORTANT: Always verify the webhook signature before processing event data.
// See https://stripe.com/docs/webhooks/signatures for verification steps.
const confirmedIntent = event.data.object; // payment_intent.succeeded payload
const transfer = await stripe.transfers.create({
  amount: 8500, // $85.00 to connected account
  currency: 'usd',
  destination: 'acct_connected_account_id',
  source_transaction: confirmedIntent.latest_charge, // charge ID from confirmed PaymentIntent
  metadata: {
    orderId: 'order_123',
  },
});
```

#### Multi-seller split

```javascript
// One payment, multiple sellers (for example, a multi-seller cart)
await stripe.paymentIntents.create({
  amount: 25000, // $250.00 total
  currency: 'usd',
});

// After payment_intent.succeeded webhook fires — latest_charge is null at creation time.
// IMPORTANT: Always verify the webhook signature before processing event data.
// See https://stripe.com/docs/webhooks/signatures for verification steps.
const confirmedIntent = event.data.object; // payment_intent.succeeded payload
const chargeId = confirmedIntent.latest_charge;

// Transfer to seller A
await stripe.transfers.create({
  amount: 8000,
  currency: 'usd',
  destination: 'acct_seller_a',
  source_transaction: chargeId,
});

// Transfer to seller B
await stripe.transfers.create({
  amount: 12000,
  currency: 'usd',
  destination: 'acct_seller_b',
  source_transaction: chargeId,
});

// Platform keeps $50 (25000 - 8000 - 12000 = 5000)
```

#### Fund flow

```
Customer pays $250
  → $250 lands in platform's balance
  → Platform creates transfer: $80 to Seller A
  → Platform creates transfer: $120 to Seller B
  → Platform keeps $50
```

#### Refunds

```javascript
// Refund the charge
const refund = await stripe.refunds.create({
  charge: 'ch_xxx',
});

// Manually reverse transfers
await stripe.transfers.createReversal('tr_seller_a', {
  amount: 8000,
});
await stripe.transfers.createReversal('tr_seller_b', {
  amount: 12000,
});
```

#### When to use

- Multi-seller carts (one payment, multiple recipients)
- Delayed payouts (hold funds, transfer later)
- Hold-and-release / delivery-gated payout (payment precedes delivery, platform releases funds on confirmation)
- Delivery-gated payouts (collect payment now, transfer to seller after fulfillment)
- Complex fee structures or splits
- When you need maximum control over fund flow timing
- Crowdfunding-style platforms

### Decision Guide

```
Is there one seller per transaction?
├── Yes → Does the platform need to hold funds before releasing to the seller?
│   ├── Yes (hold-and-release or delivery confirmation) → SEPARATE CHARGES & TRANSFERS
│   └── No → Is the seller the merchant of record?
│       ├── Yes → DIRECT CHARGES
│       └── No → DESTINATION CHARGES ← Common marketplace default
└── No (multiple sellers) → SEPARATE CHARGES & TRANSFERS
```

**Quick rules:**

- **Marketplace with one seller, immediate payout** → Destination charges
- **Marketplace with hold-and-release or delivery-gated payout** → Separate charges and transfers
- **SaaS where seller owns the relationship** → Direct charges
- **Multi-seller cart or complex splits** → Separate charges and transfers

SHA-256: e309f56bdd3ca84bc358965cfd1dc168715b1e95eff7740f7169a6be17e23fb9