Complete the public -> platform naming migration across the codebase. This aligns with the naming convention where "platform" refers to the marketing/public-facing pages of the platform itself. Changes: - Update all imports from public to platform modules - Update template references from public/ to platform/ - Update route registrations to use platform prefix - Update documentation to reflect new naming - Update test files for platform API endpoints Files affected: - app/api/main.py - router imports - app/modules/*/routes/*/platform.py - route definitions - app/modules/*/templates/*/platform/ - template files - app/modules/routes.py - route discovery - docs/* - documentation updates - tests/integration/api/v1/platform/ - test files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
617 lines
21 KiB
Markdown
617 lines
21 KiB
Markdown
# 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
|
|
|
|
```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 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
|
|
|
|
```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, 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
|
|
|
|
```python
|
|
# 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/platform/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
|
|
|
|
```javascript
|
|
// 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/platform/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/platform/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. |