Files
orion/app/modules/billing/docs/stripe-integration.md
Samir Boulahtit f141cc4e6a docs: migrate module documentation to single source of truth
Move 39 documentation files from top-level docs/ into each module's
docs/ folder, accessible via symlinks from docs/modules/. Create
data-model.md files for 10 modules with full schema documentation.
Replace originals with redirect stubs. Remove empty guide stubs.

Modules migrated: tenancy, billing, loyalty, marketplace, orders,
messaging, cms, catalog, inventory, hosting, prospecting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:38:37 +01:00

618 lines
21 KiB
Markdown

# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```javascript
// 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.