Files
famdone/.agents/skills/connect-recommend/references/charge-patterns.md
T
2026-08-20 07:35:31 +01:00

316 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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