Files
orion/docs/deployment/stripe-integration.md

21 KiB

Stripe Payment Integration - Multi-Tenant Ecommerce Platform

Architecture Overview

The payment integration uses Stripe Connect to handle multi-vendor payments, enabling:

  • Each vendor to receive payments directly
  • Platform to collect fees/commissions
  • Proper financial isolation between vendors
  • Compliance with financial regulations

Payment Models

Database Models

# models/database/payment.py
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin


class VendorPaymentConfig(Base, TimestampMixin):
    """Vendor-specific payment configuration."""
    __tablename__ = "vendor_payment_configs"

    id = Column(Integer, primary_key=True, index=True)
    vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, unique=True)
    
    # Stripe Connect configuration
    stripe_account_id = Column(String(255))  # Stripe Connect account ID
    stripe_account_status = Column(String(50))  # pending, active, restricted, inactive
    stripe_onboarding_url = Column(Text)  # Onboarding link for vendor
    stripe_dashboard_url = Column(Text)  # Vendor's Stripe dashboard
    
    # Payment settings
    accepts_payments = Column(Boolean, default=False)
    currency = Column(String(3), default="EUR")
    platform_fee_percentage = Column(Numeric(5, 2), default=2.5)  # Platform commission
    
    # Payout settings
    payout_schedule = Column(String(20), default="weekly")  # daily, weekly, monthly
    minimum_payout = Column(Numeric(10, 2), default=20.00)
    
    # Relationships
    vendor = relationship("Vendor", back_populates="payment_config")

    def __repr__(self):
        return f"<VendorPaymentConfig(vendor_id={self.vendor_id}, stripe_account_id='{self.stripe_account_id}')>"


class Payment(Base, TimestampMixin):
    """Payment records for orders."""
    __tablename__ = "payments"

    id = Column(Integer, primary_key=True, index=True)
    vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
    order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
    customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
    
    # Stripe payment details
    stripe_payment_intent_id = Column(String(255), unique=True, index=True)
    stripe_charge_id = Column(String(255), index=True)
    stripe_transfer_id = Column(String(255))  # Transfer to vendor account
    
    # Payment amounts (in cents to avoid floating point issues)
    amount_total = Column(Integer, nullable=False)  # Total customer payment
    amount_vendor = Column(Integer, nullable=False)  # Amount to vendor
    amount_platform_fee = Column(Integer, nullable=False)  # Platform commission
    currency = Column(String(3), default="EUR")
    
    # Payment status
    status = Column(String(50), nullable=False)  # pending, succeeded, failed, refunded
    payment_method = Column(String(50))  # card, bank_transfer, etc.
    
    # Metadata
    stripe_metadata = Column(Text)  # JSON string of Stripe metadata
    failure_reason = Column(Text)
    refund_reason = Column(Text)
    
    # Timestamps
    paid_at = Column(DateTime)
    refunded_at = Column(DateTime)
    
    # Relationships
    vendor = relationship("Vendor")
    order = relationship("Order", back_populates="payment")
    customer = relationship("Customer")

    def __repr__(self):
        return f"<Payment(id={self.id}, order_id={self.order_id}, status='{self.status}')>"

    @property
    def amount_total_euros(self):
        """Convert cents to euros for display."""
        return self.amount_total / 100

    @property
    def amount_vendor_euros(self):
        """Convert cents to euros for display."""
        return self.amount_vendor / 100


class PaymentMethod(Base, TimestampMixin):
    """Saved customer payment methods."""
    __tablename__ = "payment_methods"

    id = Column(Integer, primary_key=True, index=True)
    vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
    customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
    
    # Stripe payment method details
    stripe_payment_method_id = Column(String(255), nullable=False, index=True)
    payment_method_type = Column(String(50), nullable=False)  # card, sepa_debit, etc.
    
    # Card details (if applicable)
    card_brand = Column(String(50))  # visa, mastercard, etc.
    card_last4 = Column(String(4))
    card_exp_month = Column(Integer)
    card_exp_year = Column(Integer)
    
    # Settings
    is_default = Column(Boolean, default=False)
    is_active = Column(Boolean, default=True)
    
    # Relationships
    vendor = relationship("Vendor")
    customer = relationship("Customer")

    def __repr__(self):
        return f"<PaymentMethod(id={self.id}, customer_id={self.customer_id}, type='{self.payment_method_type}')>"

Updated Order Model

# Update models/database/order.py
class Order(Base, TimestampMixin):
    # ... existing fields ...
    
    # Payment integration
    payment_status = Column(String(50), default="pending")  # pending, paid, failed, refunded
    payment_intent_id = Column(String(255))  # Stripe PaymentIntent ID
    total_amount_cents = Column(Integer, nullable=False)  # Amount in cents
    
    # Relationships
    payment = relationship("Payment", back_populates="order", uselist=False)
    
    @property
    def total_amount_euros(self):
        """Convert cents to euros for display."""
        return self.total_amount_cents / 100 if self.total_amount_cents else 0

Payment Service Integration

Stripe Service

# services/payment_service.py
import stripe
import json
import logging
from decimal import Decimal
from typing import Dict, Optional
from sqlalchemy.orm import Session

from app.core.config import settings
from models.database.payment import Payment, VendorPaymentConfig
from models.database.order import Order
from models.database.vendor import Vendor
from app.exceptions.payment import *

logger = logging.getLogger(__name__)

# Configure Stripe
stripe.api_key = settings.stripe_secret_key


class PaymentService:
    """Service for handling Stripe payments in multi-tenant environment."""

    def __init__(self, db: Session):
        self.db = db

    def create_payment_intent(
        self, 
        vendor_id: int, 
        order_id: int, 
        amount_euros: Decimal,
        customer_email: str,
        metadata: Optional[Dict] = None
    ) -> Dict:
        """Create Stripe PaymentIntent for vendor order."""
        
        # Get vendor payment configuration
        payment_config = self.get_vendor_payment_config(vendor_id)
        if not payment_config.accepts_payments:
            raise PaymentNotConfiguredException(f"Vendor {vendor_id} not configured for payments")
        
        # Calculate amounts
        amount_cents = int(amount_euros * 100)
        platform_fee_cents = int(amount_cents * (payment_config.platform_fee_percentage / 100))
        vendor_amount_cents = amount_cents - platform_fee_cents
        
        try:
            # Create PaymentIntent with Stripe Connect
            payment_intent = stripe.PaymentIntent.create(
                amount=amount_cents,
                currency=payment_config.currency.lower(),
                application_fee_amount=platform_fee_cents,
                transfer_data={
                    'destination': payment_config.stripe_account_id,
                },
                metadata={
                    'vendor_id': str(vendor_id),
                    'order_id': str(order_id),
                    'platform': 'multi_tenant_ecommerce',
                    **(metadata or {})
                },
                receipt_email=customer_email,
                description=f"Order payment for vendor {vendor_id}"
            )
            
            # Create payment record
            payment = Payment(
                vendor_id=vendor_id,
                order_id=order_id,
                customer_id=self.get_order_customer_id(order_id),
                stripe_payment_intent_id=payment_intent.id,
                amount_total=amount_cents,
                amount_vendor=vendor_amount_cents,
                amount_platform_fee=platform_fee_cents,
                currency=payment_config.currency,
                status='pending',
                stripe_metadata=json.dumps(payment_intent.metadata)
            )
            
            self.db.add(payment)
            
            # Update order
            order = self.db.query(Order).filter(Order.id == order_id).first()
            if order:
                order.payment_intent_id = payment_intent.id
                order.payment_status = 'pending'
            
            self.db.commit()
            
            return {
                'payment_intent_id': payment_intent.id,
                'client_secret': payment_intent.client_secret,
                'amount_total': amount_euros,
                'amount_vendor': vendor_amount_cents / 100,
                'platform_fee': platform_fee_cents / 100,
                'currency': payment_config.currency
            }
            
        except stripe.error.StripeError as e:
            logger.error(f"Stripe error creating PaymentIntent: {e}")
            raise PaymentProcessingException(f"Payment processing failed: {str(e)}")

    def confirm_payment(self, payment_intent_id: str) -> Payment:
        """Confirm payment and update records."""
        
        try:
            # Retrieve PaymentIntent from Stripe
            payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
            
            # Find payment record
            payment = self.db.query(Payment).filter(
                Payment.stripe_payment_intent_id == payment_intent_id
            ).first()
            
            if not payment:
                raise PaymentNotFoundException(f"Payment not found for intent {payment_intent_id}")
            
            # Update payment status based on Stripe status
            if payment_intent.status == 'succeeded':
                payment.status = 'succeeded'
                payment.stripe_charge_id = payment_intent.charges.data[0].id if payment_intent.charges.data else None
                payment.paid_at = datetime.utcnow()
                
                # Update order status
                order = self.db.query(Order).filter(Order.id == payment.order_id).first()
                if order:
                    order.payment_status = 'paid'
                    order.status = 'processing'  # Move order to processing
                
            elif payment_intent.status == 'payment_failed':
                payment.status = 'failed'
                payment.failure_reason = payment_intent.last_payment_error.message if payment_intent.last_payment_error else "Unknown error"
                
                # Update order status
                order = self.db.query(Order).filter(Order.id == payment.order_id).first()
                if order:
                    order.payment_status = 'failed'
            
            self.db.commit()
            
            return payment
            
        except stripe.error.StripeError as e:
            logger.error(f"Stripe error confirming payment: {e}")
            raise PaymentProcessingException(f"Payment confirmation failed: {str(e)}")

    def create_vendor_stripe_account(self, vendor_id: int, vendor_data: Dict) -> str:
        """Create Stripe Connect account for vendor."""
        
        try:
            # Create Stripe Connect Express account
            account = stripe.Account.create(
                type='express',
                country='LU',  # Luxembourg
                email=vendor_data.get('business_email'),
                capabilities={
                    'card_payments': {'requested': True},
                    'transfers': {'requested': True},
                },
                business_type='company',
                company={
                    'name': vendor_data.get('business_name'),
                    'phone': vendor_data.get('business_phone'),
                    'address': {
                        'line1': vendor_data.get('address_line1'),
                        'city': vendor_data.get('city'),
                        'postal_code': vendor_data.get('postal_code'),
                        'country': 'LU'
                    }
                },
                metadata={
                    'vendor_id': str(vendor_id),
                    'platform': 'multi_tenant_ecommerce'
                }
            )
            
            # Update or create payment configuration
            payment_config = self.get_or_create_vendor_payment_config(vendor_id)
            payment_config.stripe_account_id = account.id
            payment_config.stripe_account_status = account.charges_enabled and account.payouts_enabled and 'active' or 'pending'
            
            self.db.commit()
            
            return account.id
            
        except stripe.error.StripeError as e:
            logger.error(f"Stripe error creating account: {e}")
            raise PaymentConfigurationException(f"Failed to create payment account: {str(e)}")

    def create_onboarding_link(self, vendor_id: int) -> str:
        """Create Stripe onboarding link for vendor."""
        
        payment_config = self.get_vendor_payment_config(vendor_id)
        if not payment_config.stripe_account_id:
            raise PaymentNotConfiguredException("Vendor does not have Stripe account")
        
        try:
            account_link = stripe.AccountLink.create(
                account=payment_config.stripe_account_id,
                refresh_url=f"{settings.frontend_url}/vendor/admin/payments/refresh",
                return_url=f"{settings.frontend_url}/vendor/admin/payments/success",
                type='account_onboarding',
            )
            
            # Update onboarding URL
            payment_config.stripe_onboarding_url = account_link.url
            self.db.commit()
            
            return account_link.url
            
        except stripe.error.StripeError as e:
            logger.error(f"Stripe error creating onboarding link: {e}")
            raise PaymentConfigurationException(f"Failed to create onboarding link: {str(e)}")

    def get_vendor_payment_config(self, vendor_id: int) -> VendorPaymentConfig:
        """Get vendor payment configuration."""
        config = self.db.query(VendorPaymentConfig).filter(
            VendorPaymentConfig.vendor_id == vendor_id
        ).first()
        
        if not config:
            raise PaymentNotConfiguredException(f"No payment configuration for vendor {vendor_id}")
        
        return config

    def webhook_handler(self, event_type: str, event_data: Dict) -> None:
        """Handle Stripe webhook events."""
        
        if event_type == 'payment_intent.succeeded':
            payment_intent_id = event_data['object']['id']
            self.confirm_payment(payment_intent_id)
            
        elif event_type == 'payment_intent.payment_failed':
            payment_intent_id = event_data['object']['id']
            self.confirm_payment(payment_intent_id)
            
        elif event_type == 'account.updated':
            # Update vendor account status
            account_id = event_data['object']['id']
            self.update_vendor_account_status(account_id, event_data['object'])
            
        # Add more webhook handlers as needed

API Endpoints

Payment APIs

# app/api/v1/vendor/payments.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from services.payment_service import PaymentService

router = APIRouter(prefix="/payments", tags=["vendor-payments"])


@router.get("/config")
async def get_payment_config(
    vendor: Vendor = Depends(require_vendor_context()),
    db: Session = Depends(get_db)
):
    """Get vendor payment configuration."""
    payment_service = PaymentService(db)
    
    try:
        config = payment_service.get_vendor_payment_config(vendor.id)
        return {
            "stripe_account_id": config.stripe_account_id,
            "account_status": config.stripe_account_status,
            "accepts_payments": config.accepts_payments,
            "currency": config.currency,
            "platform_fee_percentage": float(config.platform_fee_percentage),
            "needs_onboarding": config.stripe_account_status != 'active'
        }
    except Exception:
        return {
            "stripe_account_id": None,
            "account_status": "not_configured",
            "accepts_payments": False,
            "needs_setup": True
        }


@router.post("/setup")
async def setup_payments(
    setup_data: dict,
    vendor: Vendor = Depends(require_vendor_context()),
    db: Session = Depends(get_db)
):
    """Set up Stripe payments for vendor."""
    payment_service = PaymentService(db)
    
    vendor_data = {
        "business_name": vendor.name,
        "business_email": vendor.business_email,
        "business_phone": vendor.business_phone,
        **setup_data
    }
    
    account_id = payment_service.create_vendor_stripe_account(vendor.id, vendor_data)
    onboarding_url = payment_service.create_onboarding_link(vendor.id)
    
    return {
        "stripe_account_id": account_id,
        "onboarding_url": onboarding_url,
        "message": "Payment setup initiated. Complete onboarding to accept payments."
    }


# app/api/v1/public/vendors/payments.py
@router.post("/{vendor_id}/payments/create-intent")
async def create_payment_intent(
    vendor_id: int,
    payment_data: dict,
    db: Session = Depends(get_db)
):
    """Create payment intent for customer order."""
    payment_service = PaymentService(db)
    
    payment_intent = payment_service.create_payment_intent(
        vendor_id=vendor_id,
        order_id=payment_data['order_id'],
        amount_euros=Decimal(str(payment_data['amount'])),
        customer_email=payment_data['customer_email'],
        metadata=payment_data.get('metadata', {})
    )
    
    return payment_intent


@router.post("/webhooks/stripe")
async def stripe_webhook(
    request: Request,
    db: Session = Depends(get_db)
):
    """Handle Stripe webhook events."""
    import stripe
    
    payload = await request.body()
    sig_header = request.headers.get('stripe-signature')
    
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.stripe_webhook_secret
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")
    
    payment_service = PaymentService(db)
    payment_service.webhook_handler(event['type'], event['data'])
    
    return {"status": "success"}

Frontend Integration

Checkout Process

// frontend/js/shop/checkout.js
class CheckoutManager {
    constructor(vendorId) {
        this.vendorId = vendorId;
        this.stripe = Stripe(STRIPE_PUBLISHABLE_KEY);
        this.elements = this.stripe.elements();
        this.paymentElement = null;
    }

    async initializePayment(orderData) {
        // Create payment intent
        const response = await fetch(`/api/v1/public/vendors/${this.vendorId}/payments/create-intent`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                order_id: orderData.orderId,
                amount: orderData.total,
                customer_email: orderData.customerEmail
            })
        });

        const { client_secret, amount_total, platform_fee } = await response.json();

        // Display payment breakdown
        this.displayPaymentBreakdown(amount_total, platform_fee);

        // Create payment element
        this.paymentElement = this.elements.create('payment', {
            clientSecret: client_secret
        });

        this.paymentElement.mount('#payment-element');
    }

    async confirmPayment(orderData) {
        const { error } = await this.stripe.confirmPayment({
            elements: this.elements,
            confirmParams: {
                return_url: `${window.location.origin}/shop/order-confirmation`,
                receipt_email: orderData.customerEmail
            }
        });

        if (error) {
            this.showPaymentError(error.message);
        }
    }
}

Updated Workflow Integration

Enhanced Customer Purchase Workflow

Customer adds products to cart
    ↓
Customer proceeds to checkout
    ↓
System creates Order (payment_status: pending)
    ↓
Frontend calls POST /api/v1/public/vendors/{vendor_id}/payments/create-intent
    ↓
PaymentService creates Stripe PaymentIntent with vendor destination
    ↓
Customer completes payment with Stripe Elements
    ↓
Stripe webhook confirms payment
    ↓
PaymentService updates Order (payment_status: paid, status: processing)
    ↓
Vendor receives order for fulfillment

Payment Configuration Workflow

Vendor accesses payment settings
    ↓
POST /api/v1/vendor/payments/setup
    ↓
System creates Stripe Connect account
    ↓
Vendor completes Stripe onboarding
    ↓
Webhook updates account status to 'active'
    ↓
Vendor can now accept payments

This integration provides secure, compliant payment processing while maintaining vendor isolation and enabling proper revenue distribution between vendors and the platform.