A Stripe Connect integration enabling orgs to accept payments from their contacts (customers). System supports:
Core Components:
Data Model:
Stripe Connect Model: Standard accounts with Express onboarding
┌─────────────────────────────────────────────────────────────┐
│ Customers (PWA) │
│ - View payment options │
│ - Select payment method (Stripe, Venmo, etc.) │
│ - Complete checkout via Stripe Checkout │
└───────────────────────┬─────────────────────────────────────┘
│
│ Payment Intent
↓
┌─────────────────────────────────────────────────────────────┐
│ Backend API (FastAPI) │
│ - Create payment intents │
│ - Manage Connect accounts │
│ - Process webhooks │
│ - Record events to ledger │
└───────────────┬───────────────────────────────┬─────────────┘
│ │
│ Connect Account │ Webhook Events
│ Creation/Updates │
↓ ↓
┌───────────────────────────────┐ ┌──────────────────────────┐
│ Providers (iOS App) │ │ Stripe Connect API │
│ - Onboard via Express flow │ │ - Account creation │
│ - Complete verification │ │ - Payment routing │
│ - Link bank accounts │ │ - Disbursements │
└───────────────────────────────┘ └──────────────────────────┘
Payment Flow:
Platform Model:
Stripe Connect Benefits:
Alternatives Considered:
Direct Stripe Integration:
Stripe Connect Custom:
Stripe Connect Express (Chosen):
Express (Chosen):
Custom:
Standard:
Decision: Express balances speed, compliance, and sufficient control for MVP.
Stripe Connect offers multiple payment flow options. This architecture uses Destination Charges with Application Fees, which best fits Modfin’s marketplace model.
Stripe Connect supports several payment patterns, each with different implications for fees, disputes, branding, and control:
1. Destination Charges with Application Fees (✅ Selected)
How it works:
# Payment appears on customer's card with PLATFORM name
payment_intent = stripe.PaymentIntent.create(
amount=10000, # $100.00
currency='usd',
application_fee_amount=320, # Platform keeps $3.20 (2.9% + $0.30)
transfer_data={
'destination': connect_account_id, # Provider gets $96.80 automatically
}
)
Characteristics:
Pros:
Cons:
Best for: Marketplaces where platform brand is primary, platform wants control over customer experience.
Fee Example:
Customer pays: $100.00
└─ Stripe fee: $3.20 (platform pays this)
└─ Platform fee: $3.20 (platform keeps - covers Stripe cost)
└─ Provider receives: $96.80 (no fees deducted)
Net platform revenue: $0.00 (fee covers Stripe cost, or you can charge more)
2. Direct Charges (Alternative)
How it works:
# Payment appears on customer's card with PROVIDER name
payment_intent = stripe.PaymentIntent.create(
amount=10000,
currency='usd',
on_behalf_of=connect_account_id, # Provider's account
transfer_data={
'destination': connect_account_id,
},
application_fee_amount=320, # Platform fee collected separately
)
Characteristics:
Pros:
Cons:
on_behalf_of parameterBest for: Platforms where providers want brand visibility and dispute responsibility.
Fee Example:
Customer pays: $100.00
└─ Stripe fee: ~$2.90 (provider pays this)
└─ Platform fee: $3.20 (platform keeps, doesn't pay Stripe)
└─ Provider receives: $100.00 - $2.90 - $3.20 = $93.90
Net platform revenue: $3.20 (pure profit, no Stripe cost)
3. Separate Charges + Transfers (Advanced)
How it works:
# Step 1: Charge customer on platform account
payment_intent = stripe.PaymentIntent.create(
amount=10000,
currency='usd',
# No connect account involved initially
)
# Step 2: After payment succeeds, transfer to provider (can be delayed/batched)
transfer = stripe.Transfer.create(
amount=9680, # $100 - platform fee
currency='usd',
destination=connect_account_id,
)
Characteristics:
Pros:
Cons:
Best for: Platforms needing escrow, batching, or complex multi-party payment routing.
4. Payment Splits (Multiple Destinations)
How it works:
# Split single payment to multiple providers
payment_intent = stripe.PaymentIntent.create(
amount=10000,
currency='usd',
)
# After capture, create multiple transfers
transfer1 = stripe.Transfer.create(amount=5000, destination=provider1_account)
transfer2 = stripe.Transfer.create(amount=4000, destination=provider2_account)
# Platform keeps remaining as fee
Best for: Marketplaces where multiple providers contribute to a single purchase (e.g., group classes with multiple instructors).
| Aspect | Destination + Fees | Direct Charges | Separate + Transfer |
|---|---|---|---|
| Customer sees | Platform name | Provider name | Platform name |
| Platform pays Stripe fee? | Yes (on full amount) | No | Yes (on full amount) |
| Provider pays Stripe fee? | No | Yes (on their portion) | No |
| Who handles disputes? | Platform | Provider | Platform |
| Fund timing | Automatic on settlement | Direct to provider | Platform controlled |
| Complexity | Low | Medium | High |
| Brand control | Platform | Provider | Platform |
| Implementation | Simplest | Moderate | Most complex |
Why this flow for Modfin:
Two options for customer payment UI:
1. Stripe Checkout (Redirect) ✅ Selected for MVP
How it works:
checkout_urlPros:
Cons:
Best for: MVP, fast implementation, when redirect UX is acceptable
2. Stripe Elements (Embedded)
How it works:
client_secretstripe.confirmPayment() with client_secretPros:
Cons:
Best for: When brand consistency and no-redirect UX are critical
Decision: Stripe Checkout (redirect) for MVP - simpler, faster to implement, full payment method support. Can migrate to Elements later if needed.
Trade-offs Accepted:
When to Consider Direct Charges:
Implementation:
# Platform fee: 2.9% + $0.30
# Provider receives: remainder
def calculate_platform_fee(amount_cents: int) -> int:
"""Calculate platform fee: 2.9% + $0.30."""
percentage_fee = int(amount_cents * (PLATFORM_FEE_PERCENT / 100))
fixed_fee = PLATFORM_FEE_FIXED
return percentage_fee + fixed_fee
payment_intent = stripe.PaymentIntent.create(
amount=amount_cents,
currency='usd',
application_fee_amount=calculate_platform_fee(amount_cents),
transfer_data={
'destination': connect_account_id, # Provider's Connect account
},
metadata={
'contact_id': contact_id, # From token/DB lookup
'account_id': account_id, # From token/DB lookup
'org_id': org_id, # From token/DB lookup
}
)
Rationale Summary:
Stripe Connect accounts are managed through the existing connectors table using the connector service pattern. This approach:
public JSONB fieldnew → verifying → readyorg_singleton policy (one Stripe Connect account per org)No new tables needed - Stripe Connect is a connector service like Venmo.
Add Stripe to connector services:
# api/connectors/consts.py
class ConnectorService(enum.StrEnum):
venmo = 'venmo'
stripe = 'stripe' # Add Stripe Connect
class ServiceConfig(utils.NamespaceEnum):
venmo = dict(policy=ServicePolicy.org_singleton, processor=ServiceProcessor.none)
stripe = dict(
policy=ServicePolicy.org_singleton, # One Connect account per org
processor=ServiceProcessor.backend # Uses status flow (new->verifying->ready)
)
Connector public JSONB field structure:
# api/connectors/schemas.py
import enum
class StripeCapability(enum.StrEnum):
"""Stripe Connect account capabilities."""
charges = 'charges'
payouts = 'payouts'
details = 'details'
class StripeConnectorPublic(helpers.BaseModel):
"""Stripe Connect account data stored in connector.public JSONB field."""
# Stripe Connect Account ID (from Stripe API)
stripe_account_id: str
# Account capabilities (instead of boolean flags)
capabilities: list[StripeCapability] = []
# Cached onboarding URL (expires after 24 hours)
# Stored to avoid regenerating if user returns to complete onboarding
# Expiration checked via connector.updated_at when status is 'verifying'
onboarding_url: str | None = None # Stripe Express onboarding link
# Note: account_link_url (dashboard access) is NOT stored - generated on-demand via POST /account-link
# Optional: additional Stripe account metadata
country: str | None = None # Account country code
business_type: str | None = None # 'individual' or 'company'
requirements: dict | None = None # Current/past_due requirements from Stripe
class StripeConnectorMixin(helpers.BaseModel):
service: Literal[consts.Service.stripe]
public: StripeConnectorPublic | None = None
Connector status flow for Stripe Connect:
new: Connect account created, onboarding URL generatedverifying: Onboarding in progress (user completing Stripe Express flow)ready: Account fully onboarded (StripeCapability.charges in capabilities, can accept payments)failed: Onboarding failed or account restrictedreleased: Account was ready but is now disabledConnector public field example:
{
"stripe_account_id": "acct_1234567890",
"capabilities": ["charges", "payouts", "details"],
"onboarding_url": null, // null after onboarding complete, or if expired (checked via connector.updated_at)
"country": "US",
"business_type": "individual"
}
# Get Stripe connector for an org
connector = await OrgWideConnector.get_latest(
dbs,
owner_id=org_id,
service=consts.Service.stripe,
latest_mode=consts.ConnectorLatest.available
)
if connector and connector.public:
stripe_data = connector.public # Direct access, no nesting
account_id = stripe_data.get('stripe_account_id') if isinstance(stripe_data, dict) else stripe_data.stripe_account_id
capabilities = stripe_data.get('capabilities', []) if isinstance(stripe_data, dict) else getattr(stripe_data, 'capabilities', [])
has_charges = StripeCapability.charges.value in capabilities
No schema changes needed - just add the service enum value:
-- If using ENUM (may need migration depending on schema)
-- Otherwise, just add to Python enum, no DB change
ALTER TYPE service ADD VALUE IF NOT EXISTS 'stripe';
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Event Identification
service VARCHAR(50) NOT NULL, -- Connector service: 'stripe', 'venmo', etc.
event_id VARCHAR(255) NOT NULL, -- External event ID (from service provider)
event_type VARCHAR(255) NOT NULL,
account_id VARCHAR(255), -- Service-specific account ID (e.g., Connect account ID for Stripe)
-- Event Data
event_data JSONB NOT NULL,
-- Processing
processed BOOLEAN DEFAULT FALSE,
processed_at TIMESTAMP,
error_message TEXT,
-- Metadata
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(service, event_id), -- Prevent duplicate events per service
INDEX idx_service_event_id (service, event_id),
INDEX idx_event_type (event_type),
INDEX idx_processed (processed),
INDEX idx_account_id (account_id),
INDEX idx_service (service)
);
Note: Stripe Connect accounts are managed via the existing connector endpoints:
POST /v1/connectors/org/id/{org_id}/connector/service/stripe
Creates Connect Express account via connector pattern
Returns: Connector object with Stripe account data (including onboarding_url if needed)
GET /v1/connectors/org/id/{org_id}/connector/service/stripe
Returns connector with Stripe Connect account data
Returns: Connector object with public.stripe_account_id, public.capabilities, etc.
(Uses existing connector GET endpoints - no separate endpoint needed)
POST /v1/connectors/id/{connector_id}/refresh
Generic connector refresh endpoint (for account links and other connector operations)
Body: { type?: 'account_onboarding' | 'account_update' } # For Stripe account links
Returns: { url: string } # Account link URL, or connector-specific refresh data
Note: For Stripe, generates fresh account link URLs (expire after 24 hours).
Can be extended for other connector refresh operations.
POST /v1/payments/customer/service/{service}/checkout
Creates checkout session for customer payment
Headers: Authorization (customer token from SMS notification)
Body: {
amount: decimal,
currency: string,
description?: string,
flow?: 'redirect' | 'embedded' // Optional: 'redirect' (default) or 'embedded'
}
Returns: { checkout_url: string } OR { client_secret: string }
Token contains (from SMS notification):
- contact_id (contact/customer making payment - contact belongs to org)
- account_id (account the contact is associated with - account can have multiple contacts)
- org_id (the org - has the Connect account, has provider as member, has contact as customer)
Note: Contact and provider belong to the same org. Payment: Contact → Org.
Uses Stripe Checkout (redirect) by default. Supports Elements widget (embedded) flow.
Metadata (contact_id, account_id, org_id) encoded in PaymentIntent for webhook processing.
POST /v1/payments/webhooks/stripe
Receives Stripe Connect webhook events
Headers: stripe-signature (for verification)
Processes: account updates, payment events, payout events
Note: Separate webhook endpoint from subscriptions (/v1/subscriptions/webhooks/stripe).
Uses STRIPE_CONNECT_WEBHOOK_SECRET (subscriptions use STRIPE_WEBHOOK_SECRET).
api/
├── payments/
│ ├── __init__.py
│ ├── config.py # Stripe API keys, webhook secrets
│ ├── connect.py # Connect account operations (via connectors)
│ ├── stripe.py # Stripe Connect operations (checkout, webhooks)
│ └── routes.py # API routes
│
├── connectors/
│ ├── models.py # OrgWideConnector (used for Stripe Connect)
│ ├── schemas.py # StripeConnectorMixin (extends schemas)
│ ├── consts.py # Service.stripe enum added here
│ └── ... # Existing connector infrastructure
Note: Stripe Connect uses the existing connectors infrastructure. No separate models/crud needed.
# api/payments/config.py
import os
import stripe
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
STRIPE_CONNECT_WEBHOOK_SECRET = os.environ.get('STRIPE_CONNECT_WEBHOOK_SECRET') # Separate from STRIPE_WEBHOOK_SECRET used for subscriptions
STRIPE_CONNECT_CLIENT_ID = os.environ.get('STRIPE_CONNECT_CLIENT_ID') # OAuth client ID (if using OAuth flow)
# Platform fee configuration
PLATFORM_FEE_PERCENT = float(os.environ.get('STRIPE_PLATFORM_FEE_PERCENT', '2.9'))
PLATFORM_FEE_FIXED = int(float(os.environ.get('STRIPE_PLATFORM_FEE_FIXED', '0.30')) * 100) # cents
create_connect_account:
get_connect_account:
is_onboarding_url_expired:
get_account_status:
create_account_link:
Note: This implementation uses Destination Charges with Application Fees. See “Payment Processing Model” section above for detailed comparison of all flow options.
Platform Fee Calculation:
Checkout Session Creation (Redirect Flow):
PaymentIntent Creation (Elements Widget Flow):
process_webhook:
account.updated → handle_account_updatedpayment_intent.succeeded → handle_payment_succeededpayment_intent.payment_failed → handle_payment_failedtransfer.created → handle_transfer_createdpayout.paid → handle_payout_paidhandle_account_updated:
handle_payment_succeeded:
store_webhook_event:
POST /v1/payments/customer/service/{service}/checkout
Logic:
POST /v1/payments/webhooks/stripe
Logic:
# api/requirements.txt (additions)
stripe>=7.0.0
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_... # For client-side (if needed)
STRIPE_CONNECT_WEBHOOK_SECRET=whsec_... # Separate webhook secret for Connect (subscriptions use STRIPE_WEBHOOK_SECRET)
STRIPE_PLATFORM_FEE_PERCENT=2.9
STRIPE_PLATFORM_FEE_FIXED=0.30
Underboss/
├── APIs/
│ └── ConnectorsAPI.swift # Existing API - add refresh() method
├── Stores/
│ └── ConnectorStore.swift # Existing store - use with service='stripe'
├── Views/
│ └── StripeConnect/
│ ├── OnboardingView.swift # Onboarding entry point
│ ├── AccountStatusView.swift # Account status dashboard
│ └── OnboardingCompleteView.swift
Uses existing ConnectorsAPI (add refresh method):
createConnector(orgId, service='stripe'):
/v1/connectors/org/id/{orgId}/connector/service/stripegetConnector(orgId, service='stripe'):
/v1/connectors/org/id/{orgId}/connector/service/striperefresh(connectorId, body):
POST to /v1/connectors/id/{connectorId}/refresh with body: { type?: “account_onboarding” |
“account_update” } |
Uses existing Store.connector:
OnboardingView:
SafariView wrapper:
AccountStatusView:
Add Stripe Connect to onboarding:
stripeConnect case to existing OnboardingItem enumpwa/
├── app/
│ └── payment/
│ ├── page.tsx # Payment page
│ ├── success/
│ │ └── page.tsx # Success page
│ └── cancel/
│ └── page.tsx # Cancel page
├── lib/
│ └── api/
│ └── payments.ts # Payments API client
paymentsAPI.createCheckout:
/v1/payments/customer/service/{service}/checkoutPaymentPage component:
PaymentSuccessPage component:
PaymentCancelPage component:
Critical: Always verify webhook signatures to prevent spoofed events.
# api/payments/stripe.py (webhook processing functions)
import stripe
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_CONNECT_WEBHOOK_SECRET
)
Environment Variables:
STRIPE_CONNECT_WEBHOOK_SECRET securely (AWS Secrets Manager in production)
STRIPE_WEBHOOK_SECRET used for subscriptions webhooks/v1/payments/webhooks/stripe (Connect) vs /v1/subscriptions/webhooks/stripe (subscriptions)Provider Endpoints:
org_acls with Resource.payments)org_id matches authenticated user’s orgCustomer Endpoints:
PCI DSS Scope Reduction:
Best Practices:
Connect Account Access:
Platform Fee Calculation:
# api/stripe/tests.py
import pytest
from unittest.mock import patch, MagicMock
async def test_create_connect_account(dbs):
"""Test Connect account creation."""
with patch('stripe.Account.create') as mock_create:
mock_account = MagicMock()
mock_account.id = 'acct_123'
mock_create.return_value = mock_account
result = await connect.create_connect_account(
org_id='org_123',
email='test@example.com'
)
assert result['account_id'] == 'acct_123'
assert 'onboarding_url' in result
async def test_checkout_session_creation(dbs):
"""Test checkout session creation with Connect."""
with patch('stripe.checkout.Session.create') as mock_create:
mock_session = MagicMock()
mock_session.id = 'cs_test_123'
mock_session.url = 'https://checkout.stripe.com/...'
mock_create.return_value = mock_session
result = await stripe.create_checkout_session(
amount=Decimal('100.00'),
currency='USD',
connect_account_id='acct_123',
org_id='org_123',
contact_id='contact_123',
account_id='account_123',
description='Test payment',
)
assert 'checkout_url' in result
mock_create.assert_called_once()
async def test_webhook_processing(dbs):
"""Test webhook event processing."""
event = {
'id': 'evt_123',
'type': 'payment_intent.succeeded',
'data': {
'object': {
'id': 'pi_123',
'amount': 10000,
'currency': 'usd',
'metadata': {
'contact_id': 'contact_123',
'account_id': 'account_123',
'org_id': 'org_123',
},
'created': 1234567890,
}
}
}
with patch('stripe.Webhook.construct_event', return_value=event):
await stripe.handle_payment_succeeded(event)
# Verify event stored
stored = await crud.get_webhook_event(dbs, service='stripe', event_id='evt_123')
assert stored is not None
assert stored.processed is True
Test Cards:
4242 4242 4242 4242 - Success4000 0000 0000 0002 - Card declined4000 0000 0000 9995 - Insufficient fundsTest Scenarios:
Webhook Testing:
stripe listen --forward-to localhost:8000/v1/payments/webhooks/stripestripe trigger payment_intent.succeededEnd-to-End Flow:
Alternative Payment Flows:
on_behalf_of parameter in PaymentIntentMulti-Currency Support:
Payout Scheduling:
Payment Methods:
Advanced Connect Features:
Provider Dashboard:
Platform Analytics:
KYC/AML:
Fraud Prevention:
Dispute Management:
“Account not ready for payments”
StripeCapability.charges is in capabilities array“Webhook signature verification failed”
STRIPE_CONNECT_WEBHOOK_SECRET matches Stripe Dashboard (not STRIPE_WEBHOOK_SECRET used for subscriptions)/v1/payments/webhooks/stripe for Connect vs /v1/subscriptions/webhooks/stripe for subscriptions“PaymentIntent creation failed”
StripeCapability.charges in capabilities array“Onboarding URL expired”
# Backend (.env)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_CONNECT_WEBHOOK_SECRET=whsec_... # Connect webhook secret (subscriptions use STRIPE_WEBHOOK_SECRET)
STRIPE_PLATFORM_FEE_PERCENT=2.9
STRIPE_PLATFORM_FEE_FIXED=0.30
PWA_URL=https://app.modfin.io
# iOS (Info.plist or environment)
STRIPE_PUBLISHABLE_KEY=pk_test_... # If using Stripe SDK client-side
# PWA (.env.local)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # If using Stripe.js