notes

Stripe Connect Architecture Document

Overview

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


Architecture

┌─────────────────────────────────────────────────────────────┐
│                        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:

  1. Provider Setup (iOS):
    • Provider initiates onboarding → Backend creates Connect account → Redirect to Stripe Express
    • Provider completes verification → Stripe webhook → Backend updates account status
    • Provider can view account status and earnings
  2. Customer Payment (PWA):
    • Customer receives notification → Opens customer payment page
    • Customer selects payment method (Stripe, Venmo, etc.) → Backend creates Checkout Session
    • Customer completes Stripe Checkout → Payment captured → Funds split (platform + provider)
    • Webhook received → Backend records payment event to ledger using metadata (contact_id, account_id, org_id)
  3. Payouts:
    • Automatic via Stripe (Express accounts handle transfers automatically)
    • Backend tracks payout events via webhooks

Design Rationale

Why Stripe Connect?

Platform Model:

Stripe Connect Benefits:

Alternatives Considered:

Direct Stripe Integration:

Stripe Connect Custom:

Stripe Connect Express (Chosen):

Account Type: Express vs Custom vs Standard

Express (Chosen):

Custom:

Standard:

Decision: Express balances speed, compliance, and sufficient control for MVP.

Payment Processing Model

Stripe Connect offers multiple payment flow options. This architecture uses Destination Charges with Application Fees, which best fits Modfin’s marketplace model.

Payment Flow Options

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:

Best 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).


Comparison Table

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

Decision: Destination Charges with Application Fees

Why this flow for Modfin:


Checkout Flow Options

Two options for customer payment UI:

1. Stripe Checkout (Redirect)Selected for MVP

How it works:

Pros:

Cons:

Best for: MVP, fast implementation, when redirect UX is acceptable


2. Stripe Elements (Embedded)

How it works:

Pros:

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.


  1. Platform Control:
    • Unified customer experience (all payments show “Modfin” brand)
    • Centralized customer support
    • Consistent payment UI across all providers
  2. Simplified Provider Experience:
    • Providers don’t need to understand Stripe fees
    • They see simple net amounts
    • No dispute handling burden on providers
  3. Implementation Simplicity:
    • Single API call creates payment and split
    • Automatic transfers (no manual management)
    • Straightforward reconciliation
  4. Customer Trust:
    • Customers pay Modfin (recognized platform brand)
    • Consistent billing experience
    • Platform handles all customer issues

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:


Database Schema

Stripe Connect via Connectors Table

Stripe Connect accounts are managed through the existing connectors table using the connector service pattern. This approach:

No new tables needed - Stripe Connect is a connector service like Venmo.

Connector Service Configuration

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)
    )

Stripe Connect Data in Connector Schema

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:

Connector 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"
}

Finding Stripe Connect Account

# 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

Migration: Add Stripe Service

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';

Webhook Events Table (service-agnostic)

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)
);

REST API Endpoints

Connect Account Management

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.

Customer Payment Processing

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.

Webhooks

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).

Backend Implementation

Project Structure

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.

Stripe Configuration

# 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

Connect Account Operations (via Connector Pattern)

create_connect_account:

  1. Create Stripe Connect Express account via Stripe API:
    • type=’express’
    • country, email
    • metadata: org_id
    • capabilities: card_payments, transfers (requested)
  2. Create onboarding account link via Stripe API:
    • type=’account_onboarding’
    • refresh_url and return_url (PWA URLs)
  3. Create/upsert connector with:
    • service=’stripe’
    • public.stripe_account_id = Stripe account ID
    • public.capabilities = [] (no capabilities yet)
    • public.onboarding_url = account link URL (cached)
    • status = ‘verifying’ (indicates onboarding started)
  4. Return connector

get_connect_account:

  1. Query OrgWideConnector by owner_id (org_id) and service=’stripe’
  2. Return latest available connector or None

is_onboarding_url_expired:

  1. If connector missing or updated_at missing, return True
  2. If connector.status != ‘verifying’, return True
  3. Calculate expiration: connector.updated_at + 24 hours
  4. Return True if current time > expiration, else False

get_account_status:

  1. Get connector for org_id
  2. If no connector or no public data, return None
  3. Retrieve latest account status from Stripe API
  4. Build capabilities list from Stripe account:
    • If charges_enabled → add ‘charges’
    • If payouts_enabled → add ‘payouts’
    • If details_submitted → add ‘details’
  5. Update connector.public.capabilities
  6. Clear onboarding_url if:
    • Account has ‘charges’ capability (fully onboarded), OR
    • onboarding_url exists and is_expired()
  7. Determine connector status:
    • Has ‘charges’ → ready
    • Has ‘details’ (but not ‘charges’) → verifying
    • Otherwise → new
  8. Update connector if status or capabilities changed
  9. Return account_id, capabilities, requirements, connector_status, onboarding_url

create_account_link:

  1. Get connector for org_id
  2. Extract stripe_account_id from connector.public
  3. Create Stripe AccountLink:
    • type: ‘account_onboarding’ or ‘account_update’
    • refresh_url and return_url
  4. If type = ‘account_onboarding’:
    • Update connector.public.onboarding_url (cache for user to return)
  5. Return { url: account_link.url }

Stripe Checkout and Webhook Processing

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:

  1. Calculate percentage fee: amount_cents × 2.9%
  2. Add fixed fee: $0.30 (30 cents)
  3. With Destination Charges flow:
    • Platform pays Stripe fee on full amount
    • Platform keeps application_fee_amount as revenue
    • Provider receives remainder with no Stripe fees deducted

Checkout Session Creation (Redirect Flow):

  1. Convert amount to cents
  2. Calculate platform fee (2.9% + $0.30)
  3. Create Stripe Checkout Session with:
    • application_fee_amount (platform fee)
    • transfer_data.destination (provider’s Connect account ID)
    • metadata (contact_id from token, account_id from token, org_id from token - the org)
    • line_items (amount, currency, description)
    • success_url and cancel_url
  4. Return checkout_url

PaymentIntent Creation (Elements Widget Flow):

  1. Convert amount to cents
  2. Calculate platform fee (2.9% + $0.30)
  3. Create Stripe PaymentIntent with:
    • application_fee_amount (platform fee)
    • transfer_data.destination (provider’s Connect account ID)
    • metadata (contact_id from token, account_id from token, org_id from token - the org)
    • automatic_payment_methods enabled
  4. Return client_secret

Webhook Processing

process_webhook:

  1. Extract payload and signature from request
  2. Verify webhook signature using STRIPE_CONNECT_WEBHOOK_SECRET
    • Note: Separate from STRIPE_WEBHOOK_SECRET used for subscriptions webhooks
  3. If invalid signature, return error 400
  4. Store event in webhook_events table for audit/replay
  5. Route to handler based on event.type:
    • account.updated → handle_account_updated
    • payment_intent.succeeded → handle_payment_succeeded
    • payment_intent.payment_failed → handle_payment_failed
    • transfer.created → handle_transfer_created
    • payout.paid → handle_payout_paid
  6. Return { received: true }

handle_account_updated:

  1. Extract account_id from event.data.object
  2. Find connector by stripe_account_id in connector.public JSONB field
  3. If connector not found, log warning and return
  4. Build capabilities list from Stripe account status:
    • If charges_enabled → add ‘charges’
    • If payouts_enabled → add ‘payouts’
    • If details_submitted → add ‘details’
  5. Determine connector status from capabilities:
    • Has ‘charges’ → ready
    • Has ‘details’ (but not ‘charges’) → verifying
    • Otherwise → new
  6. Update connector.public.capabilities and connector.status
  7. Dispatch event for app-side notifications

handle_payment_succeeded:

  1. Extract payment_intent from event.data.object
  2. Extract metadata (contact_id, account_id, org_id)
  3. If metadata missing, log warning and return
  4. Convert amount from cents to dollars
  5. Create ledger event with:
    • org_id, event=’payment’, amount, currency
    • metadata: payment_intent_id, contact_id, account_id, payment_method=’stripe’

store_webhook_event:

  1. Insert into webhook_events table:
    • service=’stripe’
    • event_id (Stripe event ID)
    • event_type
    • account_id (Connect account ID if present)
    • event_data (full event JSON)

API Routes

POST /v1/payments/customer/service/{service}/checkout

Logic:

  1. Validate amount and currency are provided
  2. Extract identifiers from customer token:
    • contact_id (contact/customer making payment - belongs to org)
    • account_id (account contact is associated with - can have multiple contacts)
    • org_id (the org - has Connect account, has provider as member, has contact as customer)
  3. Get flow type from body (default: ‘redirect’)
  4. Find connector for service:
    • Query OrgWideConnector by owner_id=org_id, service=service
    • (org_id is the org where Connect account is set up and where contact/provider belong)
  5. Validate connector exists and is configured
  6. For Stripe service:
    • Validate connector has ‘charges’ capability
    • Extract connect_account_id from connector.public.stripe_account_id
  7. If flow = ‘redirect’ (or not specified):
    • Create Stripe Checkout Session
    • Return { checkout_url: string }
  8. If flow = ‘embedded’:
    • Create Stripe PaymentIntent
    • Return { client_secret: string }

POST /v1/payments/webhooks/stripe

Logic:

  1. Call stripe.process_webhook(request, db_session)
  2. Returns { received: true }

Dependencies

# api/requirements.txt (additions)
stripe>=7.0.0

Environment Variables

# .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

iOS App Implementation (Provider Onboarding)

Project Structure

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

API Client

Uses existing ConnectorsAPI (add refresh method):

  1. createConnector(orgId, service='stripe'):
    • POST to /v1/connectors/org/id/{orgId}/connector/service/stripe
    • Include auth token in Authorization header
    • Returns Connector object with public.onboarding_url, public.stripe_account_id
  2. getConnector(orgId, service='stripe'):
    • GET from /v1/connectors/org/id/{orgId}/connector/service/stripe
    • Include auth token in Authorization header
    • Returns Connector object with public (stripe_account_id, capabilities, onboarding_url), status
  3. refresh(connectorId, body):
    • POST to /v1/connectors/id/{connectorId}/refresh with body: { type?: “account_onboarding” “account_update” }
    • Include auth token in Authorization header
    • Returns { url: string } (account link URL for Stripe)

Store (State Management)

Uses existing Store.connector:

Onboarding View

OnboardingView:

  1. Get connector ID (from orgId/service lookup or passed as parameter)
  2. On view appear:
    • Call Store.connector.get(connectorId)
  3. If isLoading:
    • Show ProgressView
  4. If connector exists:
    • Extract capabilities from connector.public.capabilities
    • If ‘charges’ in capabilities:
      • Show OnboardingCompleteView
    • Else:
      • Show “Complete Your Payment Setup” message
      • Show “Continue Setup” button
      • On button tap:
        • If connector.public.onboarding_url exists: use it
        • Else: Call ConnectorsAPI.createConnector(orgId, service=’stripe’) then call Store.connector.get(newConnectorId)
        • Open onboarding URL in SafariViewController (sheet modal)
  5. If error exists:
    • Display error message
  6. When SafariViewController dismissed:
    • Reload connector via Store.connector.get(connectorId) to check if onboarding completed

SafariView wrapper:

  1. Create UIViewControllerRepresentable wrapper around SFSafariViewController
  2. Display Stripe onboarding URL in Safari
  3. Handle modal dismissal

Account Status View

AccountStatusView:

  1. Get connector ID (from orgId/service lookup or passed as parameter)
  2. On view appear:
    • Call Store.connector.get(connectorId)
  3. Display status indicator:
    • Extract capabilities from connector.public.capabilities
    • If ‘charges’ in capabilities: green checkmark + “Account Ready”
    • Else: orange exclamation + “Setup Incomplete”
  4. If ‘charges’ in capabilities:
    • Display status rows:
      • Charges: check if ‘charges’ in capabilities
      • Payouts: check if ‘payouts’ in capabilities
    • Show “View Stripe Dashboard” button
    • On button tap:
      • Call ConnectorsAPI.refresh(connectorId, body: { type: “account_update” })
      • Open URL from response in SafariViewController (sheet modal)
  5. If isLoading:
    • Show ProgressView

Integration into Onboarding Flow

Add Stripe Connect to onboarding:

  1. Add stripeConnect case to existing OnboardingItem enum
  2. Define title: “Payment Setup”
  3. Define description: “Connect your bank account to receive payments”
  4. In OnboardingProgressView:
    • If item == .stripeConnect:
      • Show NavigationLink to StripeConnectOnboardingView(orgId)

PWA Implementation (Customer Payments)

Project Structure

pwa/
├── app/
│   └── payment/
│       ├── page.tsx               # Payment page
│       ├── success/
│       │   └── page.tsx           # Success page
│       └── cancel/
│           └── page.tsx           # Cancel page
├── lib/
│   └── api/
│       └── payments.ts            # Payments API client

API Client

paymentsAPI.createCheckout:

  1. POST to /v1/payments/customer/service/{service}/checkout
  2. Include customer token in Authorization header (from SMS notification)
  3. Body: { amount, currency, description?, flow? }
  4. Returns: { checkout_url: string } OR { client_secret: string }

Payment Page

PaymentPage component:

  1. Load payment details (amount, currency, description) from customer account/notification context
  2. Query available payment methods (connectors) for customer’s org
  3. Display payment details:
    • Amount and currency
    • Description
  4. Display available payment methods:
    • Filter to show only available methods
    • For each method: Show button with service name
  5. On payment method button click:
    • Set loading state for selected service
    • Call paymentsAPI.createCheckout(service, { amount, currency, description, flow })
    • If flow=’redirect’ (or not specified):
      • Redirect to checkout_url (window.location.href)
    • If flow=’embedded’:
      • Use client_secret with Stripe Elements widget (embedded payment form)
  6. Handle errors:
    • Display error message if checkout creation fails
    • Reset loading state

Success Page

PaymentSuccessPage component:

  1. Extract session_id from URL query params (if available)
  2. Display success message:
    • Green checkmark icon
    • “Payment Successful!” heading
    • Confirmation message
  3. Show “Return Home” button that navigates to home page

Cancel Page

PaymentCancelPage component:

  1. Display cancel message:
    • Orange X icon
    • “Payment Cancelled” heading
    • Message that payment wasn’t completed
  2. Show action buttons:
    • “Go Back” button (router.back())
    • “Return Home” button (router.push(‘/’))

Security Considerations

Webhook Verification

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:

API Authentication

Provider Endpoints:

Customer Endpoints:

PCI Compliance

PCI DSS Scope Reduction:

Best Practices:

Account Security

Connect Account Access:

Platform Fee Calculation:


Testing Strategy

Backend Testing

# 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

Stripe Test Mode

Test Cards:

Test Scenarios:

Webhook Testing:

Integration Testing

End-to-End Flow:

  1. Create Connect account (iOS app → Backend → Stripe)
  2. Complete onboarding (Stripe dashboard)
  3. Customer receives notification → Opens payment page
  4. Customer selects Stripe → Backend creates checkout session
  5. Customer completes payment (PWA → Stripe Checkout)
  6. Verify webhook processing
  7. Verify ledger entry created with contact_id, account_id from metadata

Deployment Checklist

Backend

iOS App

PWA

Stripe Dashboard


Future Enhancements

Phase 2: Enhanced Features

Alternative Payment Flows:

Multi-Currency Support:

Payout Scheduling:

Payment Methods:

Advanced Connect Features:

Phase 3: Analytics & Reporting

Provider Dashboard:

Platform Analytics:

Phase 4: Compliance & Risk

KYC/AML:

Fraud Prevention:

Dispute Management:


Troubleshooting

Common Issues

“Account not ready for payments”

“Webhook signature verification failed”

“PaymentIntent creation failed”

“Onboarding URL expired”


References


Appendix: Environment Variables Reference

# 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

Change Log