Refunds
import 'package:firebase_functions_interop/firebase_functions_interop.dart';
import 'package:stripe_dart/stripe_dart.dart';
void main() {
functions['stripeWebhook'] = functions.https.onRequest(handleStripeWebhook);
}
Future<void> handleStripeWebhook(ExpressHttpRequest request) async {
final rawBody = await request.body;
final signature = request.headers['stripe-signature'];
try {
final event = Stripe.constructEvent(
payload: rawBody,
sigHeader: signature,
endpointSecret: functions.config().stripe.webhook_secret,
);
switch (event.type) {
case 'charge.dispute.created':
await processDisputeCreated(event.data.object);
break;
case 'charge.dispute.updated':
await processDisputeUpdated(event.data.object);
break;
}
request.response.write('{"received": true}');
} catch (e) {
request.response.statusCode = 400;
request.response.write('Webhook Error: $e');
}
}
Future<void> processDisputeCreated(Map<String, dynamic> dispute) async {
// Automated dispute handling
final disputeId = dispute['id'];
final chargeId = dispute['charge'];
// Send push notifications to admin app
await sendPushNotification({
'type': 'dispute_created',
'disputeId': disputeId,
'chargeId': chargeId,
'amount': dispute['amount'],
'reason': dispute['reason'],
});
// Update Firestore
await FirebaseFirestore.instance
.collection('disputes')
.doc(disputeId)
.set({
'id': disputeId,
'chargeId': chargeId,
'status': 'needs_response',
'createdAt': FieldValue.serverTimestamp(),
'dueBy': DateTime.fromMillisecondsSinceEpoch(
dispute['evidence_details']['due_by'] * 1000),
});
}
from datetime import datetime, timedelta
from enum import Enum
class RefundPolicy(Enum):
DIGITAL_DOWNLOAD = 7 # 7 days
SAAS_MONTHLY = 30 # 30 days
SAAS_ANNUAL = 60 # 60 days
PHYSICAL_GOODS = 30 # 30 days
CUSTOM_SERVICES = 0 # No refunds
EU_CONSUMER = 14 # EU legal requirement
def check_refund_eligibility(charge_id: str) -> dict:
"""Check if customer can request refund based on your policies"""
# Get charge details
charge = stripe.Charge.retrieve(charge_id)
purchase_date = datetime.fromtimestamp(charge['created'])
days_elapsed = (datetime.now() - purchase_date).days
# Determine product type from metadata
product_type = charge['metadata'].get('product_type', 'default')
customer_country = charge['billing_details']['address']['country']
# Apply policy rules
if customer_country in ['FR', 'DE', 'ES', 'IT']: # EU countries
policy_days = max(RefundPolicy.EU_CONSUMER.value,
RefundPolicy[product_type.upper()].value)
else:
policy_days = RefundPolicy[product_type.upper()].value
is_eligible = days_elapsed <= policy_days if policy_days > 0 else False
return {
'eligible': is_eligible,
'policy_days': policy_days,
'days_elapsed': days_elapsed,
'days_remaining': max(0, policy_days - days_elapsed) if policy_days > 0 else 0,
'reason': get_eligibility_reason(is_eligible, policy_days, days_elapsed)
}
def get_eligibility_reason(eligible: bool, policy_days: int, days_elapsed: int) -> str:
if policy_days == 0:
return "No refunds policy - all sales final"
elif eligible:
return f"Within {policy_days}-day refund window"
else:
return f"Refund window expired ({days_elapsed} days > {policy_days} day limit)"
# Usage example
eligibility = check_refund_eligibility("ch_1234567890")
print(f"Refund eligible: {eligibility['eligible']}")
print(f"Reason: {eligibility['reason']}")
User: @stripe using client.v1 how do you process a refund. how do you handle application fees and stripe fees
Stripe: To process a refund using client.v1, you create a refund object with the payment intent or charge ID. Here’s how to handle refunds with application fees:
import stripe
stripe.api_key = "sk_test_51Sy8NyJHoIadtaBidwXWWi0ikBTmlTEnZYMq3usCGvkl918mcw4GvFM7GrdGtmgDBudXGuPmmqEm5GOZ5TcYvAeJ00Di5CuUbD"
# Basic refund
refund = stripe.Refund.create(
payment_intent="pi_123456789",
amount=1000, # Amount in cents
)
# Refund with application fee handling
refund = stripe.Refund.create(
payment_intent="pi_123456789",
amount=1000,
refund_application_fee=True, # Returns proportional app fee to connected account
)
# Partial refund (returns proportional application fee)
refund = stripe.Refund.create(
payment_intent="pi_123456789",
amount=500, # 50% refund
refund_application_fee=True, # Returns 50% of application fee
)
Application Fee Handling:
- By default, refunds don’t return application fees to connected accounts
- Set
refund_application_fee=Trueto return a proportional amount of the application fee - For a 40% refund on a charge with a 5 USD application fee, 2 USD of the application fee is returned
Stripe Fee Handling:
- Refunds come from your platform’s balance
- Processing fees from the original transaction are not returned (except for some bank transfer cases)
- If your balance is insufficient, the refund status becomes
pendinguntil funds are available
For Connect platforms using destination charges:
# Refund and reverse the transfer to connected account
refund = stripe.Refund.create(
payment_intent="pi_123456789",
reverse_transfer=True, # Pulls funds back from connected account
refund_application_fee=True,
)
The refund amount is proportional - if you refund the entire charge, the entire transfer is reversed. For partial refunds, a proportional amount is reversed from the connected account.
See Handle refunds and disputes for complete refund handling in Connect platforms.
User: @stripe how do you handle refunds for connected accounts.
Stripe: For connected accounts, refund handling depends on your Connect integration type:
For direct charges, create refunds while authenticated as the connected account:
import stripe
stripe.api_key = "sk_test_51Sy8NyJHoIadtaBidwXWWi0ikBTmlTEnZYMq3usCGvkl918mcw4GvFM7GrdGtmgDBudXGuPmmqEm5GOZ5TcYvAeJ00Di5CuUbD"
# Refund on connected account
refund = stripe.Refund.create(
payment_intent="pi_123456789",
amount=1000,
refund_application_fee=True,
stripe_account="acct_connected_account_id" # Connected account ID
)
For indirect charges (destination charges or separate charges and transfers), refunds are always debited from your platform balance:
# Platform processes the refund
refund = stripe.Refund.create(
payment_intent="pi_123456789",
amount=1000,
reverse_transfer=True, # Recovers funds from connected account
refund_application_fee=True,
)
Key behaviors:
- Stripe debits the refund amount from your platform balance
- Use
reverse_transfer=Trueto recover funds from the connected account - If the connected account has insufficient balance, Stripe holds a reserve on their available balance
- For US/Canada accounts with
debit_negative_balances=true, Stripe automatically attempts to debit their external account
When connected accounts lack sufficient funds for refunds:
# Check connected account balance first
balance = stripe.Balance.retrieve(
stripe_account="acct_connected_account_id"
)
# If insufficient, you can:
# 1. Wait for the account to receive more funds
# 2. Use account debits to charge their external account
# 3. Implement a manual process to collect funds
Best practices:
- Set up webhooks for
charge.dispute.createdevents to automate responses - Consider delayed payouts for new businesses to maintain a refund window
- Use Connect embedded components to allow connected accounts to manage disputes
- Implement balance checks before processing refunds
Express accounts cannot initiate refunds themselves - all refunds must be processed by the platform through the dashboard or API.
See Handle refunds and disputes and Handle refunds and disputes for complete guidance on Connect refund handling.
User: @stripe
if i have connected express and full accounts and the customer is charged using:
intent = self.client.v1.payment_intents.confirm(
payment_intent_id,
params={"payment_method": payment_method_id},
options=_default_stripe_options,
)
using an intent created with:
intent = self.client.v1.payment_intents.create(
options=_default_stripe_options,
params={
"amount": amount,
"currency": rcpt_currency_code,
"customer": customer_id,
"capture_method": "manual", # Auth only — no immediate capture
"setup_future_usage": "off_session", # Saves payment method to customer
"payment_method_types": ["card"],
"on_behalf_of": account_id,
"transfer_data": {"destination": account_id},
"application_fee_amount": application_fee_amount,
"expand": ["payment_method"],
},
)
i assume this transfers the funds to the connected account immediately
how can i refund the customer?
Stripe: Based on your code, you’re using destination charges with on_behalf_of and transfer_data. The funds don’t transfer immediately - they transfer when you capture the payment intent (since you’re using capture_method: "manual").
For destination charges, you process refunds from your platform account and Stripe handles recovering funds from the connected account:
# Refund the customer (processed by platform)
refund = self.client.v1.refunds.create(
params={
"payment_intent": payment_intent_id,
"amount": refund_amount, # Optional - omit for full refund
"reverse_transfer": True, # Recovers funds from connected account
"refund_application_fee": True, # Returns app fee to connected account
},
options=_default_stripe_options
)
Refund flow:
- Refund is debited from your platform balance
reverse_transfer=Truerecovers the proportional amount from the connected account’s balancerefund_application_fee=Truereturns the proportional application fee to the connected account- Customer receives the refund
If connected account has insufficient funds:
- Stripe creates a negative balance on the connected account
- For Express accounts, Stripe may attempt to debit their external account (if enabled)
- For Custom accounts, you’re responsible for collecting the funds
# Partial refund example
refund = self.client.v1.refunds.create(
params={
"payment_intent": payment_intent_id,
"amount": 500, # 50% of original amount
"reverse_transfer": True, # Reverses 50% of transfer
"refund_application_fee": True, # Returns 50% of app fee
},
options=_default_stripe_options
)
Key points:
- You don’t need the connected account’s credentials to process refunds
- Refunds are always initiated by the platform for destination charges
- The connected account cannot initiate refunds themselves
- Processing fees from the original transaction are typically not refunded
See Handle refunds and disputes for more details on destination charge refunds.
User: @stripe what is Set up webhooks for charge.dispute.created events to automate responses
Stripe: Setting up webhooks for charge.dispute.created events allows you to automatically respond when customers dispute charges with their banks. Here’s how to implement this:
The bottom line: Balance checks give you visibility and options, but true protection comes from reserves, delayed payouts, and risk management - not just checking balances.
User: @stripe are there standard time periods the customer is allowed to request a refund.
Stripe: There are no universal standard time periods for customer-requested refunds - these are determined by your business policies rather than Stripe or payment network requirements. However, there are some common practices and related regulations:
Common refund windows:
- 7 days - Digital products, software subscriptions
- 14 days - EU consumer rights minimum for distance selling
- 30 days - Common for physical products, SaaS trials
- 60-90 days - Premium services, extended warranties
- No refunds - Event tickets, custom products, digital downloads
EU Consumer Rights:
# EU customers have legal right to 14-day cooling off period
def check_refund_eligibility(customer_location, purchase_date, product_type):
if customer_location in EU_COUNTRIES:
days_elapsed = (datetime.now() - purchase_date).days
if product_type == 'digital' and not_downloaded:
return days_elapsed <= 14
elif product_type == 'physical':
return days_elapsed <= 14
elif product_type == 'service' and service_not_started:
return days_elapsed <= 14
return check_business_policy_eligibility(purchase_date)
US State Laws:
- Most states don’t require refund periods
- Some states require posted refund policies to be honored
- FTC “Cooling Off Rule” applies to door-to-door sales (3 days)
While not “refund” periods, customers can dispute charges through their banks:
Credit/Debit Cards:
- 60-120 days for most disputes
- 540 days for certain fraud claims
- Varies by card network (Visa, Mastercard, etc.)
ACH/Bank Transfers:
- 60 days for unauthorized transactions
- 2 business days for same-day ACH
from datetime import datetime, timedelta
from enum import Enum
class RefundPolicy(Enum):
DIGITAL_DOWNLOAD = 7 # 7 days
SAAS_MONTHLY = 30 # 30 days
SAAS_ANNUAL = 60 # 60 days
PHYSICAL_GOODS = 30 # 30 days
CUSTOM_SERVICES = 0 # No refunds
EU_CONSUMER = 14 # EU legal requirement
def check_refund_eligibility(charge_id: str) -> dict:
"""Check if customer can request refund based on your policies"""
# Get charge details
charge = stripe.Charge.retrieve(charge_id)
purchase_date = datetime.fromtimestamp(charge['created'])
days_elapsed = (datetime.now() - purchase_date).days
# Determine product type from metadata
product_type = charge['metadata'].get('product_type', 'default')
customer_country = charge['billing_details']['address']['country']
# Apply policy rules
if customer_country in ['FR', 'DE', 'ES', 'IT']: # EU countries
policy_days = max(RefundPolicy.EU_CONSUMER.value,
RefundPolicy[product_type.upper()].value)
else:
policy_days = RefundPolicy[product_type.upper()].value
is_eligible = days_elapsed <= policy_days if policy_days > 0 else False
return {
'eligible': is_eligible,
'policy_days': policy_days,
'days_elapsed': days_elapsed,
'days_remaining': max(0, policy_days - days_elapsed) if policy_days > 0 else 0,
'reason': get_eligibility_reason(is_eligible, policy_days, days_elapsed)
}
def get_eligibility_reason(eligible: bool, policy_days: int, days_elapsed: int) -> str:
if policy_days == 0:
return "No refunds policy - all sales final"
elif eligible:
return f"Within {policy_days}-day refund window"
else:
return f"Refund window expired ({days_elapsed} days > {policy_days} day limit)"
# Usage example
eligibility = check_refund_eligibility("ch_1234567890")
print(f"Refund eligible: {eligibility['eligible']}")
print(f"Reason: {eligibility['reason']}")
Technical refund limits:
- Must refund within 90 days for bank transfers (AU BECS, NZ BECS)
- No technical time limit for card refunds
- Cannot refund more than the original charge amount
Clear communication:
def generate_refund_policy_text(product_type: str, customer_country: str) -> str:
eligibility = check_refund_eligibility_for_product(product_type, customer_country)
if eligibility['days'] == 0:
return "All sales are final. No refunds available."
elif customer_country in EU_COUNTRIES:
return f"EU customers: {eligibility['days']}-day refund period (consumer rights)"
else:
return f"Refund available within {eligibility['days']} days of purchase"
Automated enforcement:
@https_fn.on_request()
def process_refund_request(req):
charge_id = req.json.get('charge_id')
eligibility = check_refund_eligibility(charge_id)
if not eligibility['eligible']:
return {
'success': False,
'message': eligibility['reason'],
'policy_info': 'Contact support for exceptional circumstances'
}
# Process refund
refund = stripe.Refund.create(charge=charge_id)
return {'success': True, 'refund_id': refund['id']}
The key takeaway: Refund time periods are business decisions you control, not payment processing requirements. Set policies that balance customer satisfaction with business protection, and clearly communicate them to customers.
User: @stripe aside from the chargeback fees are the flows for refunds and chargebacks similar?
Stripe: While refunds and chargebacks both return money to customers, their flows are quite different in terms of control, timing, and financial impact:
- Initiated by: Merchant (you) voluntarily
- Timeline: Immediate when you process it
- Fees: Processing fees typically not returned, but no additional fees
- Control: You decide if/when/how much to refund
- Initiated by: Customer through their bank
- Timeline: 7-21 days response window, weeks/months to resolve
- Fees: Chargeback fees ($15-$100+) plus disputed amount withdrawn
- Control: You can only respond with evidence to dispute it
# 1. You decide to process refund
refund = stripe.Refund.create(
payment_intent="pi_123456789",
amount=5000, # Optional - can be partial
reason="requested_by_customer" # Optional
)
# 2. Money flows immediately:
# - Platform balance: -$50 (you pay refund)
# - Connected account: -$50 (via reverse_transfer)
# - Customer: +$50 (gets money back)
# 3. Done - no dispute process
# 1. Webhook notification (you don't initiate this)
@https_fn.on_request()
def handle_dispute_webhook(req):
event = stripe.Webhook.construct_event(req.data, sig, secret)
if event['type'] == 'charge.dispute.created':
dispute = event['data']['object']
# 2. Funds immediately withdrawn from your account
# - Platform balance: -$50 (disputed amount)
# - Platform balance: -$15 (chargeback fee)
# - Customer: +$50 (bank already returned money)
# 3. You have limited time to respond
due_by = dispute['evidence_details']['due_by'] # Usually 7-21 days
# 4. Submit evidence to fight it
stripe.Dispute.modify(
dispute['id'],
evidence={
'receipt': 'receipt_url',
'shipping_documentation': 'tracking_info',
'customer_communication': 'email_logs'
}
)
# 5. Wait for bank decision (weeks/months)
# - If you win: get disputed amount back (keep chargeback fee)
# - If you lose: lose disputed amount + chargeback fee
Both refunds and chargebacks affect connected accounts similarly:
# Both scenarios result in:
def impact_on_connected_account(account_id, amount):
"""Both refunds and chargebacks create negative balances"""
# Platform pays upfront in both cases
platform_balance_impact = -amount
# Connected account balance goes negative
connected_balance_impact = -amount
# Platform must recover funds from connected account
recovery_methods = [
'reverse_transfer', # For refunds
'transfer_reversal', # For chargebacks
'debit_external_account', # If enabled
'hold_future_payouts' # Create reserve
]
return {
'platform_pays_immediately': True,
'connected_account_negative': True,
'recovery_required': True
}
Both can trigger similar automated responses:
def automated_response(transaction_type, charge_id):
"""Similar automation for both refunds and chargebacks"""
common_actions = [
'update_order_status',
'notify_customer_service',
'flag_customer_account',
'update_fraud_metrics',
'check_connected_account_balance'
]
if transaction_type == 'chargeback':
# Additional chargeback-specific actions
common_actions.extend([
'gather_dispute_evidence',
'submit_evidence_automatically',
'start_dispute_timer'
])
return common_actions
Refunds are defensive:
- Prevent chargebacks by proactively refunding
- Maintain customer relationships
- Avoid chargeback fees and dispute ratios
Chargebacks are reactive:
- Respond to customer/bank actions
- Fight with evidence when you believe you’re right
- Accept when fighting isn’t worth the cost
def decide_refund_vs_fight_chargeback(charge_id, dispute_reason):
"""Strategic decision making"""
if dispute_reason == 'fraudulent':
return 'fight' # Usually worth fighting fraud claims
elif dispute_reason == 'subscription_canceled':
return 'accept' # Hard to win service disputes
elif dispute_reason == 'product_not_received':
if has_delivery_proof(charge_id):
return 'fight'
else:
return 'accept'
return 'evaluate_case_by_case'
Bottom line: While both return money to customers, refunds are controlled business decisions while chargebacks are imposed financial disputes with additional fees and complexity.
User: @stripe what about disputes
Stripe: “Disputes” and “chargebacks” are essentially the same thing in Stripe’s terminology - Stripe uses “dispute” to refer to what the banking industry calls “chargebacks.” Let me clarify the terminology and process:
Stripe uses “Dispute” for what banks call “Chargeback”:
charge.dispute.createdwebhook = chargeback initiatedstripe.Dispute.modify()= respond to chargeback- Dispute reason codes = chargeback reason codes
def handle_complete_dispute_lifecycle():
"""All dispute events in Stripe"""
# 1. Dispute created (chargeback filed)
if event['type'] == 'charge.dispute.created':
dispute = event['data']['object']
# - Funds immediately withdrawn from your balance
# - Chargeback fee charged ($15+)
# - Customer already has money back from their bank
handle_dispute_created(dispute)
# 2. Dispute updated (evidence submitted or other changes)
elif event['type'] == 'charge.dispute.updated':
# Evidence was submitted or dispute status changed
handle_dispute_updated(event['data']['object'])
# 3. Dispute closed - final outcome
elif event['type'] == 'charge.dispute.closed':
dispute = event['data']['object']
if dispute['status'] == 'lost':
# You lost - keep the loss and chargeback fee
handle_dispute_lost(dispute)
elif dispute['status'] == 'won':
# You won - disputed amount returned (keep chargeback fee)
handle_dispute_won(dispute)
elif dispute['status'] == 'accepted':
# You accepted - same as lost
handle_dispute_accepted(dispute)
Key takeaway: “Disputes” in Stripe = “chargebacks” in banking terminology. The strategic choice is whether to fight disputes with evidence or proactively refund to prevent them entirely. Both approaches have different cost structures and risk profiles.
User: @stripe how do i Use Connect embedded components to allow connected accounts to manage disputes
Stripe: Connect embedded components allow your connected accounts to manage disputes directly within your platform interface. Here’s how to implement dispute management components:
Stripe provides several embedded components for dispute management:
- Disputes list component: Shows all disputes for a connected account
- Disputes for a payment component: Shows disputes for a specific payment
- Payments component: Includes dispute management alongside payment data
import stripe
stripe.api_key = "sk_test_51Sy8NyJHoIadtaBidwXWWi0ikBTmlTEnZYMq3usCGvkl918mcw4GvFM7GrdGtmgDBudXGuPmmqEm5GOZ5TcYvAeJ00Di5CuUbD"
def create_account_session_with_disputes(connected_account_id):
"""Create account session enabling dispute management components"""
account_session = stripe.AccountSession.create(
account=connected_account_id,
components={
'disputes_list': {
'enabled': True,
'features': {
'dispute_management': True,
'refund_management': True,
'capture_payments': True,
'destination_on_behalf_of_charge_management': False
}
},
'payments': {
'enabled': True,
'features': {
'dispute_management': True,
'refund_management': True,
'capture_payments': True
}
}
}
)
return account_session.client_secret
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/connect-js/v2.0/stripe-connect.js"></script>
</head>
<body>
<!-- Disputes List Component -->
<div id="disputes-list"></div>
<!-- Payments Component (includes dispute management) -->
<div id="payments-list"></div>
<script>
// Initialize Stripe Connect
const stripeConnect = StripeConnect('pk_test_51Sy8NyJHoIadtaBiuieDzwPqQPK6eu9UsxuN41ROc4CAvjf5fOWifndmwtPyhmZtThmTNFdnCttgOAwuX6m2XxMh008o23eeLE');
// Mount disputes list component
const disputesList = stripeConnect.create('disputes_list', {
accountSessionClientSecret: 'acs_123...', // From your backend
});
disputesList.mount('#disputes-list');
// Mount payments component with dispute features
const paymentsList = stripeConnect.create('payments', {
accountSessionClientSecret: 'acs_123...',
});
paymentsList.mount('#payments-list');
// Handle events
disputesList.on('dispute_updated', (event) => {
console.log('Dispute updated:', event.dispute);
// Update your internal records
updateDisputeInDatabase(event.dispute);
});
</script>
</body>
</html>
import { ConnectJSProvider, useStripeConnect } from '@stripe/react-connect-js';
import { loadConnectAndInitialize } from '@stripe/connect-js';
// Initialize Connect
const stripeConnectInstance = loadConnectAndInitialize({
publishableKey: 'pk_test_51Sy8NyJHoIadtaBiuieDzwPqQPK6eu9UsxuN41ROc4CAvjf5fOWifndmwtPyhmZtThmTNFdnCttgOAwuX6m2XxMh008o23eeLE',
});
function DisputeManagement({ accountSessionClientSecret }) {
return (
<ConnectJSProvider connectInstance={stripeConnectInstance}>
<DisputesListComponent
accountSessionClientSecret={accountSessionClientSecret}
/>
<PaymentsComponent
accountSessionClientSecret={accountSessionClientSecret}
/>
</ConnectJSProvider>
);
}
function DisputesListComponent({ accountSessionClientSecret }) {
const stripeConnect = useStripeConnect();
const [disputesList, setDisputesList] = useState(null);
useEffect(() => {
if (stripeConnect) {
const disputes = stripeConnect.create('disputes_list', {
accountSessionClientSecret,
});
// Handle dispute events
disputes.on('dispute_updated', handleDisputeUpdate);
disputes.on('evidence_submitted', handleEvidenceSubmitted);
setDisputesList(disputes);
}
}, [stripeConnect, accountSessionClientSecret]);
useEffect(() => {
if (disputesList) {
disputesList.mount('#disputes-list-mount-point');
}
return () => {
if (disputesList) {
disputesList.unmount();
}
};
}, [disputesList]);
return <div id="disputes-list-mount-point" />;
}
function handleDisputeUpdate(event) {
// Send update to your backend
fetch('/api/dispute-updated', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
disputeId: event.dispute.id,
status: event.dispute.status,
accountId: event.account.id
})
});
}
function handleEvidenceSubmitted(event) {
// Track evidence submission
console.log('Evidence submitted for dispute:', event.dispute.id);
}
from firebase_functions import https_fn
import stripe
@https_fn.on_call()
def get_dispute_session(req):
"""Create account session for connected account dispute management"""
# Verify the connected account belongs to the authenticated user
connected_account_id = req.data.get('connected_account_id')
user_id = req.auth.uid
if not verify_account_ownership(user_id, connected_account_id):
raise https_fn.HttpsError('permission-denied', 'Account access denied')
try:
account_session = stripe.AccountSession.create(
account=connected_account_id,
components={
'disputes_list': {
'enabled': True,
'features': {
'dispute_management': True,
'refund_management': True,
}
},
'payments': {
'enabled': True,
'features': {
'dispute_management': True,
'refund_management': True,
}
}
}
)
return {
'client_secret': account_session.client_secret,
'account_id': connected_account_id
}
except stripe.error.StripeError as e:
raise https_fn.HttpsError('internal', f'Failed to create session: {str(e)}')
def verify_account_ownership(user_id, account_id):
"""Verify that the user owns the connected account"""
# Check your database
user_accounts = get_user_connected_accounts(user_id)
return account_id in user_accounts
@https_fn.on_call()
def handle_dispute_webhook_for_embedded(req):
"""Handle dispute events and notify embedded components"""
event = stripe.Webhook.construct_event(
req.get_data(),
req.headers.get('stripe-signature'),
webhook_secret
)
if event['type'] in ['charge.dispute.created', 'charge.dispute.updated']:
dispute = event['data']['object']
connected_account = get_connected_account_from_charge(dispute['charge'])
# Update your database
update_dispute_in_database(dispute, connected_account)
# Notify the connected account (optional)
send_dispute_notification(connected_account, dispute)
return {'received': True}
def create_payment_dispute_session(connected_account_id, payment_intent_id):
"""Create session for managing disputes on a specific payment"""
account_session = stripe.AccountSession.create(
account=connected_account_id,
components={
'disputes_for_a_payment': {
'enabled': True,
'features': {
'dispute_management': True,
}
}
}
)
return account_session.client_secret
// Mount dispute component for specific payment
const paymentDisputes = stripeConnect.create('disputes_for_a_payment', {
accountSessionClientSecret: 'acs_123...',
paymentIntent: 'pi_1234567890', // Specific payment intent ID
});
paymentDisputes.mount('#payment-disputes');
Through embedded components, connected accounts can:
- View all their disputes in a filterable list
- See dispute details (reason, amount, evidence requirements)
- Upload evidence documents and text responses
- Track dispute status and deadlines
- Manage disputes for specific payments
- Process refunds to resolve disputes
- View dispute history and outcomes
Component features:
- Responsive design that matches your site styling
- Real-time updates on dispute status changes
- File upload for evidence documents
- Guided evidence collection based on dispute reason
- Deadline tracking and notifications
Embedded components provide:
- Connected accounts stay within your platform
- Consistent branding and user experience
- You maintain control over the interface
- Better integration with your business logic
- Customizable styling and behavior
See Supported Connect embedded components and Disputes list component for complete implementation details.
User: @stripe is a dispute is raised can is it possible to provide a link to the connected users dashboard to manage the dispute using the stripe dashboard?
Stripe: Yes, you can provide connected accounts with direct access to the Stripe Dashboard to manage disputes. The method depends on the type of connected account:
Standard accounts can log in directly to the Stripe Dashboard using the normal login page:
def get_dispute_management_info(connected_account_id, dispute_id):
"""Get dispute management options for connected accounts"""
account = stripe.Account.retrieve(connected_account_id)
if account['type'] == 'standard':
return {
'dashboard_access': 'direct_login',
'login_url': 'https://dashboard.stripe.com/login',
'dispute_url': f'https://dashboard.stripe.com/disputes/{dispute_id}',
'instructions': 'Log in with your Stripe account credentials to manage disputes'
}
Express accounts get limited dashboard access. You can create login links for them:
def create_express_dashboard_link(connected_account_id, dispute_id=None):
"""Create login link for Express account dispute management"""
try:
login_link = stripe.Account.create_login_link(
connected_account_id,
# Optionally redirect to specific dispute
redirect_url=f'https://dashboard.stripe.com/disputes/{dispute_id}' if dispute_id else None
)
return {
'dashboard_access': 'login_link',
'login_url': login_link.url,
'expires_at': login_link.created + 3600, # Links expire after 1 hour
'instructions': 'Use this link to access your Stripe Dashboard'
}
except stripe.error.StripeError as e:
return {
'error': f'Cannot create dashboard access: {str(e)}',
'fallback': 'Use embedded components instead'
}
Custom accounts have no dashboard access by default. You need to use embedded components or build your own interface:
def get_custom_account_dispute_options(connected_account_id):
"""Custom accounts cannot access Stripe Dashboard"""
return {
'dashboard_access': 'none',
'available_options': [
'embedded_components',
'custom_interface_via_api',
'platform_managed_disputes'
],
'recommendation': 'Use Connect embedded components for dispute management'
}
@https_fn.on_call()
def get_dispute_management_link(req):
"""Get appropriate dispute management access for connected account"""
user_id = req.auth.uid
dispute_id = req.data.get('dispute_id')
# Get dispute details
dispute = stripe.Dispute.retrieve(dispute_id)
charge = stripe.Charge.retrieve(dispute['charge'])
connected_account_id = charge.get('on_behalf_of') or charge.get('destination')
if not connected_account_id:
raise https_fn.HttpsError('invalid-argument', 'No connected account found')
# Verify user owns this account
if not verify_account_ownership(user_id, connected_account_id):
raise https_fn.HttpsError('permission-denied', 'Account access denied')
account = stripe.Account.retrieve(connected_account_id)
account_type = account['type']
if account_type == 'standard':
return {
'access_type': 'direct_login',
'login_url': 'https://dashboard.stripe.com/login',
'dispute_url': f'https://dashboard.stripe.com/disputes/{dispute_id}',
'message': 'Log in to your Stripe Dashboard to manage this dispute'
}
elif account_type == 'express':
try:
login_link = stripe.Account.create_login_link(connected_account_id)
return {
'access_type': 'login_link',
'login_url': login_link.url,
'expires_in_minutes': 60,
'dispute_direct_url': f'{login_link.url}#/disputes/{dispute_id}',
'message': 'Click this link to access your Stripe Dashboard'
}
except Exception as e:
# Fallback to embedded components
return create_embedded_component_session(connected_account_id)
else: # Custom account
return {
'access_type': 'embedded_only',
'message': 'Use the dispute management interface below',
'embedded_session': create_embedded_component_session(connected_account_id)
}
def create_embedded_component_session(connected_account_id):
"""Fallback to embedded components"""
account_session = stripe.AccountSession.create(
account=connected_account_id,
components={
'disputes_list': {
'enabled': True,
'features': {'dispute_management': True}
}
}
)
return {
'client_secret': account_session.client_secret,
'instructions': 'Manage disputes using the interface below'
}
async function showDisputeManagementOptions(disputeId) {
try {
const response = await firebase.functions()
.httpsCallable('get_dispute_management_link')({
dispute_id: disputeId
});
const options = response.data;
if (options.access_type === 'direct_login') {
// Standard account - show login instructions
showDirectLoginOption(options);
} else if (options.access_type === 'login_link') {
// Express account - show login link
showLoginLinkOption(options);
} else {
// Custom account - show embedded components
showEmbeddedComponents(options.embedded_session);
}
} catch (error) {
console.error('Error getting dispute management options:', error);
}
}
function showDirectLoginOption(options) {
const html = `
<div class="dispute-management">
<h3>Manage Your Dispute</h3>
<p>${options.message}</p>
<a href="${options.dispute_url}"
target="_blank"
class="btn btn-primary">
Open Stripe Dashboard
</a>
</div>
`;
document.getElementById('dispute-container').innerHTML = html;
}
function showLoginLinkOption(options) {
const html = `
<div class="dispute-management">
<h3>Manage Your Dispute</h3>
<p>${options.message}</p>
<p><strong>Note:</strong> This link expires in ${options.expires_in_minutes} minutes</p>
<a href="${options.login_url}"
target="_blank"
class="btn btn-primary">
Access Your Dashboard
</a>
</div>
`;
document.getElementById('dispute-container').innerHTML = html;
}
Security considerations:
- Always verify account ownership before providing dashboard access
- Login links for Express accounts expire after 1 hour
- Standard account users should use strong authentication
User experience:
- Provide clear instructions for each account type
- Consider embedded components as the primary option for better UX
- Use dashboard links as a fallback or advanced option
Account type recommendations:
- Standard accounts: Direct dashboard access works well
- Express accounts: Login links provide good balance of access and control
- Custom accounts: Embedded components are the only viable option
The dashboard approach gives connected accounts full Stripe functionality but takes them outside your platform’s interface. Embedded components keep users within your platform while still providing dispute management capabilities.
User: @stripe is there a way to charge the customer but delay transfer to the connected account for a period from 3 days to 14 days (depending on risk profile), before stripe automatically transfers the funds to the connected account.
Stripe: Yes, you can delay transfers to connected accounts, but the approach depends on your Connect integration pattern. Here are the main methods:
This gives you complete control over when funds are transferred:
import stripe
from datetime import datetime, timedelta
stripe.api_key = "sk_test_51Sy8NyJHoIadtaBidwXWWi0ikBTmlTEnZYMq3usCGvkl918mcw4GvFM7GrdGtmgDBudXGuPmmqEm5GOZ5TcYvAeJ00Di5CuUbD"
def charge_with_delayed_transfer(customer_id, amount, connected_account_id, delay_days):
"""Charge customer immediately, transfer to connected account after delay"""
# 1. Create charge on your platform account
charge = stripe.Charge.create(
amount=amount,
currency='usd',
customer=customer_id,
description=f"Payment for service (transfer delayed {delay_days} days)"
)
# 2. Store transfer instruction for later processing
schedule_delayed_transfer(
charge_id=charge['id'],
connected_account_id=connected_account_id,
amount=amount - calculate_platform_fee(amount),
transfer_date=datetime.now() + timedelta(days=delay_days)
)
return charge
def schedule_delayed_transfer(charge_id, connected_account_id, amount, transfer_date):
"""Schedule transfer for future execution"""
# Store in your database for scheduled processing
from firebase_admin import firestore
db = firestore.client()
db.collection('scheduled_transfers').add({
'charge_id': charge_id,
'connected_account_id': connected_account_id,
'amount': amount,
'transfer_date': transfer_date,
'status': 'scheduled',
'created_at': firestore.SERVER_TIMESTAMP
})
# Scheduled function to process transfers
@firebase_functions.scheduler_fn.on_schedule(schedule="0 */4 * * *") # Every 4 hours
def process_scheduled_transfers(event):
"""Process transfers that are due"""
db = firestore.client()
# Get transfers due for processing
now = datetime.now()
due_transfers = db.collection('scheduled_transfers')\
.where('status', '==', 'scheduled')\
.where('transfer_date', '<=', now)\
.get()
for transfer_doc in due_transfers:
transfer_data = transfer_doc.to_dict()
try:
# Execute the transfer
transfer = stripe.Transfer.create(
amount=transfer_data['amount'],
currency='usd',
destination=transfer_data['connected_account_id'],
source_transaction=transfer_data['charge_id']
)
# Update status
transfer_doc.reference.update({
'status': 'completed',
'transfer_id': transfer['id'],
'completed_at': firestore.SERVER_TIMESTAMP
})
except Exception as e:
# Handle transfer failures
transfer_doc.reference.update({
'status': 'failed',
'error': str(e),
'failed_at': firestore.SERVER_TIMESTAMP
})
Set up connected accounts with delayed payouts and manual payout schedules:
def create_account_with_delayed_payouts(email, delay_days):
"""Create connected account with delayed payouts"""
account = stripe.Account.create(
type='express',
email=email,
settings={
'payouts': {
'schedule': {
'interval': 'manual' # Disable automatic payouts
}
}
}
)
# Store the delay configuration
store_account_delay_config(account['id'], delay_days)
return account
def update_existing_account_payout_schedule(account_id, delay_days):
"""Update existing account to use delayed payouts"""
stripe.Account.modify(
account_id,
settings={
'payouts': {
'schedule': {
'interval': 'manual'
}
}
}
)
store_account_delay_config(account_id, delay_days)
# Destination charges with manual payout control
def charge_with_destination_and_delay(customer_id, amount, connected_account_id):
"""Use destination charges but control when payouts happen"""
charge = stripe.Charge.create(
amount=amount,
currency='usd',
customer=customer_id,
destination={
'account': connected_account_id,
'amount': amount - calculate_application_fee(amount)
},
application_fee_amount=calculate_application_fee(amount)
)
# Funds go to connected account balance but won't auto-payout
# Schedule manual payout based on risk profile
delay_days = calculate_delay_based_on_risk(connected_account_id)
schedule_manual_payout(connected_account_id, delay_days)
return charge
@firebase_functions.scheduler_fn.on_schedule(schedule="0 8 * * *") # Daily at 8 AM
def process_manual_payouts(event):
"""Process manual payouts for connected accounts"""
accounts_due_for_payout = get_accounts_due_for_payout()
for account_info in accounts_due_for_payout:
try:
# Get available balance
balance = stripe.Balance.retrieve(
stripe_account=account_info['account_id']
)
available_amount = balance['available'][0]['amount']
if available_amount > 0:
# Create manual payout
payout = stripe.Payout.create(
amount=available_amount,
currency='usd',
stripe_account=account_info['account_id']
)
log_payout_processed(account_info['account_id'], payout['id'])
except Exception as e:
log_payout_error(account_info['account_id'], str(e))
To modify your existing payment intent creation:
def create_payment_intent_with_delayed_transfer(self, trans: AppChargeTransaction):
"""Modified version of your existing method with delayed transfers"""
# Calculate delay based on risk
delay_days = calculate_delay_based_on_risk(
trans.recipient.account_id,
trans.payment.amount
)
if delay_days > 1:
# Use separate charges and transfers for delayed processing
intent = self.client.v1.payment_intents.create(
params={
"amount": trans.payment.amount,
"currency": trans.payment.currency_code,
"customer": trans.sender.customer_id,
"capture_method": "manual",
"setup_future_usage": "off_session",
"payment_method_types": ["card"],
# No on_behalf_of or transfer_data - we'll handle transfer separately
},
options=_default_stripe_options,
)
# Schedule the transfer
schedule_delayed_transfer(
charge_id=None, # Will be set after confirmation
connected_account_id=trans.recipient.account_id,
amount=trans.payment.amount - trans.charge.conservative_app_fee_amount(trans.payment.currency_code),
transfer_date=datetime.now() + timedelta(days=delay_days),
payment_intent_id=intent.id
)
else:
# Use your existing immediate transfer logic
intent = self.client.v1.payment_intents.create(
params={
"amount": trans.payment.amount,
"currency": trans.payment.currency_code,
"customer": trans.sender.customer_id,
"capture_method": "manual",
"setup_future_usage": "off_session",
"payment_method_types": ["card"],
"on_behalf_of": trans.recipient.account_id,
"transfer_data": {"destination": trans.recipient.account_id},
"application_fee_amount": trans.charge.conservative_app_fee_amount(trans.payment.currency_code),
},
options=_default_stripe_options,
)
return intent
Key benefits of this approach:
- Complete control over transfer timing
- Risk-based delay periods (3-14 days)
- Ability to cancel transfers if disputes arise
- Maintains customer payment experience
- Provides buffer for fraud detection
The separate charges and transfers method is most flexible and gives you the control you need for risk-based delays.