Files
orion/docs/deployment/stripe-integration.md
Samir Boulahtit d648c921b7
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
docs: add consolidated dev URL reference and migrate /shop to /storefront
- Add Development URL Quick Reference section to url-routing overview
  with all login URLs, entry points, and full examples
- Replace /shop/ path segments with /storefront/ across 50 docs files
- Update file references: shop_pages.py → storefront_pages.py,
  templates/shop/ → templates/storefront/, api/v1/shop/ → api/v1/storefront/
- Preserve domain references (orion.shop) and /store/ staff dashboard paths
- Archive docs left unchanged (historical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:23:44 +01:00

21 KiB

Stripe Payment Integration - Multi-Tenant Ecommerce Platform

Architecture Overview

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

  • Each store to receive payments directly
  • Platform to collect fees/commissions
  • Proper financial isolation between stores
  • 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 StorePaymentConfig(Base, TimestampMixin):
    """Store-specific payment configuration."""
    __tablename__ = "store_payment_configs"

    id = Column(Integer, primary_key=True, index=True)
    store_id = Column(Integer, ForeignKey("stores.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 store
    stripe_dashboard_url = Column(Text)  # Store'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
    store = relationship("Store", back_populates="payment_config")

    def __repr__(self):
        return f"<StorePaymentConfig(store_id={self.store_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)
    store_id = Column(Integer, ForeignKey("stores.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 store account

    # Payment amounts (in cents to avoid floating point issues)
    amount_total = Column(Integer, nullable=False)  # Total customer payment
    amount_store = Column(Integer, nullable=False)  # Amount to store
    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
    store = relationship("Store")
    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_store_euros(self):
        """Convert cents to euros for display."""
        return self.amount_store / 100


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

    id = Column(Integer, primary_key=True, index=True)
    store_id = Column(Integer, ForeignKey("stores.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
    store = relationship("Store")
    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, StorePaymentConfig
from models.database.order import Order
from models.database.store import Store
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,
        store_id: int,
        order_id: int,
        amount_euros: Decimal,
        customer_email: str,
        metadata: Optional[Dict] = None
    ) -> Dict:
        """Create Stripe PaymentIntent for store order."""

        # Get store payment configuration
        payment_config = self.get_store_payment_config(store_id)
        if not payment_config.accepts_payments:
            raise PaymentNotConfiguredException(f"Store {store_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))
        store_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={
                    'store_id': str(store_id),
                    'order_id': str(order_id),
                    'platform': 'multi_tenant_ecommerce',
                    **(metadata or {})
                },
                receipt_email=customer_email,
                description=f"Order payment for store {store_id}"
            )

            # Create payment record
            payment = Payment(
                store_id=store_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_store=store_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_store': store_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_store_stripe_account(self, store_id: int, store_data: Dict) -> str:
        """Create Stripe Connect account for store."""

        try:
            # Create Stripe Connect Express account
            account = stripe.Account.create(
                type='express',
                country='LU',  # Luxembourg
                email=store_data.get('business_email'),
                capabilities={
                    'card_payments': {'requested': True},
                    'transfers': {'requested': True},
                },
                business_type='merchant',
                merchant={
                    'name': store_data.get('business_name'),
                    'phone': store_data.get('business_phone'),
                    'address': {
                        'line1': store_data.get('address_line1'),
                        'city': store_data.get('city'),
                        'postal_code': store_data.get('postal_code'),
                        'country': 'LU'
                    }
                },
                metadata={
                    'store_id': str(store_id),
                    'platform': 'multi_tenant_ecommerce'
                }
            )

            # Update or create payment configuration
            payment_config = self.get_or_create_store_payment_config(store_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, store_id: int) -> str:
        """Create Stripe onboarding link for store."""

        payment_config = self.get_store_payment_config(store_id)
        if not payment_config.stripe_account_id:
            raise PaymentNotConfiguredException("Store does not have Stripe account")

        try:
            account_link = stripe.AccountLink.create(
                account=payment_config.stripe_account_id,
                refresh_url=f"{settings.frontend_url}/store/admin/payments/refresh",
                return_url=f"{settings.frontend_url}/store/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_store_payment_config(self, store_id: int) -> StorePaymentConfig:
        """Get store payment configuration."""
        config = self.db.query(StorePaymentConfig).filter(
            StorePaymentConfig.store_id == store_id
        ).first()

        if not config:
            raise PaymentNotConfiguredException(f"No payment configuration for store {store_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 store account status
            account_id = event_data['object']['id']
            self.update_store_account_status(account_id, event_data['object'])

        # Add more webhook handlers as needed

API Endpoints

Payment APIs

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

from app.core.database import get_db
from middleware.store_context import require_store_context
from models.database.store import Store
from services.payment_service import PaymentService

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


@router.get("/config")
async def get_payment_config(
    store: Store = Depends(require_store_context()),
    db: Session = Depends(get_db)
):
    """Get store payment configuration."""
    payment_service = PaymentService(db)

    try:
        config = payment_service.get_store_payment_config(store.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,
    store: Store = Depends(require_store_context()),
    db: Session = Depends(get_db)
):
    """Set up Stripe payments for store."""
    payment_service = PaymentService(db)

    store_data = {
        "business_name": store.name,
        "business_email": store.business_email,
        "business_phone": store.business_phone,
        **setup_data
    }

    account_id = payment_service.create_store_stripe_account(store.id, store_data)
    onboarding_url = payment_service.create_onboarding_link(store.id)

    return {
        "stripe_account_id": account_id,
        "onboarding_url": onboarding_url,
        "message": "Payment setup initiated. Complete onboarding to accept payments."
    }


# app/api/v1/platform/stores/payments.py
@router.post("/{store_id}/payments/create-intent")
async def create_payment_intent(
    store_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(
        store_id=store_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/storefront/checkout.js
class CheckoutManager {
    constructor(storeId) {
        this.storeId = storeId;
        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/platform/stores/${this.storeId}/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}/storefront/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/platform/stores/{store_id}/payments/create-intent
    ↓
PaymentService creates Stripe PaymentIntent with store destination
    ↓
Customer completes payment with Stripe Elements
    ↓
Stripe webhook confirms payment
    ↓
PaymentService updates Order (payment_status: paid, status: processing)
    ↓
Store receives order for fulfillment

Payment Configuration Workflow

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

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