21 KiB
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.