Phase 3 & Phase 4 - Completed

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-07 00:57:26 +00:00
parent 4b6a03a898
commit 909ed1cb17
25 changed files with 5549 additions and 215 deletions

View File

@@ -0,0 +1,868 @@
"""
PayPal Views - Order, Capture, Subscription, and Webhook endpoints
PayPal Payment Flow:
1. Client calls create-order endpoint
2. Client redirects user to PayPal approval URL
3. User approves payment on PayPal
4. PayPal redirects to return_url with order_id
5. Client calls capture-order endpoint to complete payment
6. Webhook receives confirmation
Endpoints:
- POST /billing/paypal/create-order/ - Create order for credit package
- POST /billing/paypal/create-subscription-order/ - Create order for plan subscription
- POST /billing/paypal/capture-order/ - Capture approved order
- POST /billing/paypal/create-subscription/ - Create PayPal subscription
- POST /billing/webhooks/paypal/ - Handle PayPal webhooks
- GET /billing/paypal/config/ - Get PayPal configuration
"""
import json
import logging
from django.conf import settings
from django.utils import timezone
from django.db import transaction
from django.views.decorators.csrf import csrf_exempt
from rest_framework.views import APIView
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from igny8_core.api.response import success_response, error_response
from igny8_core.api.permissions import IsAuthenticatedAndActive
from igny8_core.auth.models import Plan, Account, Subscription
from ..models import CreditPackage, Payment, Invoice, CreditTransaction
from ..services.paypal_service import PayPalService, PayPalConfigurationError, PayPalAPIError
from ..services.invoice_service import InvoiceService
from ..services.credit_service import CreditService
logger = logging.getLogger(__name__)
class PayPalConfigView(APIView):
"""Return PayPal configuration for frontend initialization"""
permission_classes = [AllowAny]
def get(self, request):
"""Get PayPal client ID and configuration"""
try:
service = PayPalService()
return Response({
'client_id': service.client_id,
'is_sandbox': service.is_sandbox,
'currency': service.currency,
})
except PayPalConfigurationError as e:
return Response(
{'error': str(e), 'configured': False},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
class PayPalCreateOrderView(APIView):
"""Create PayPal order for credit package purchase"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Create PayPal order for credit package.
Request body:
{
"package_id": "uuid",
"return_url": "optional",
"cancel_url": "optional"
}
Returns:
{
"order_id": "...",
"approval_url": "https://www.paypal.com/checkoutnow?token=...",
"status": "CREATED"
}
"""
account = request.user.account
package_id = request.data.get('package_id')
if not package_id:
return error_response(
error='package_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get credit package
try:
package = CreditPackage.objects.get(id=package_id, is_active=True)
except CreditPackage.DoesNotExist:
return error_response(
error='Credit package not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
try:
service = PayPalService()
# Get optional URLs
return_url = request.data.get('return_url')
cancel_url = request.data.get('cancel_url')
order = service.create_credit_order(
account=account,
credit_package=package,
return_url=return_url,
cancel_url=cancel_url,
)
# Store order info in session or cache for later verification
# The credit_package_id is embedded in the return_url
return success_response(
data=order,
message='PayPal order created',
request=request
)
except PayPalConfigurationError as e:
logger.error(f"PayPal configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except PayPalAPIError as e:
logger.error(f"PayPal API error: {e}")
return error_response(
error=f'PayPal error: {str(e)}',
status_code=status.HTTP_502_BAD_GATEWAY,
request=request
)
except Exception as e:
logger.exception(f"PayPal create order error: {e}")
return error_response(
error='Failed to create PayPal order',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class PayPalCreateSubscriptionOrderView(APIView):
"""Create PayPal order for plan subscription (one-time payment model)"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Create PayPal order for plan subscription.
Note: This uses one-time payment model. For recurring PayPal subscriptions,
use PayPalCreateSubscriptionView with PayPal Plans.
Request body:
{
"plan_id": "uuid",
"return_url": "optional",
"cancel_url": "optional"
}
Returns:
{
"order_id": "...",
"approval_url": "https://www.paypal.com/checkoutnow?token=...",
"status": "CREATED"
}
"""
account = request.user.account
plan_id = request.data.get('plan_id')
if not plan_id:
return error_response(
error='plan_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get plan
try:
plan = Plan.objects.get(id=plan_id, is_active=True)
except Plan.DoesNotExist:
return error_response(
error='Plan not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
try:
service = PayPalService()
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
# Build return URL with plan info
return_url = request.data.get(
'return_url',
f'{frontend_url}/account/plans?paypal=success&plan_id={plan_id}'
)
cancel_url = request.data.get(
'cancel_url',
f'{frontend_url}/account/plans?paypal=cancel'
)
# Create order for plan price
order = service.create_order(
account=account,
amount=float(plan.price),
description=f'{plan.name} Plan Subscription',
return_url=return_url,
cancel_url=cancel_url,
metadata={
'plan_id': str(plan_id),
'type': 'subscription',
}
)
# Add plan info to response
order['plan_id'] = str(plan_id)
order['plan_name'] = plan.name
return success_response(
data=order,
message='PayPal subscription order created',
request=request
)
except PayPalConfigurationError as e:
logger.error(f"PayPal configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except PayPalAPIError as e:
logger.error(f"PayPal API error: {e}")
return error_response(
error=f'PayPal error: {str(e)}',
status_code=status.HTTP_502_BAD_GATEWAY,
request=request
)
except Exception as e:
logger.exception(f"PayPal create subscription order error: {e}")
return error_response(
error='Failed to create PayPal order',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class PayPalCaptureOrderView(APIView):
"""Capture approved PayPal order"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Capture PayPal order after user approval.
Request body:
{
"order_id": "PayPal order ID",
"package_id": "optional - credit package UUID",
"plan_id": "optional - plan UUID for subscription"
}
Returns:
{
"order_id": "...",
"capture_id": "...",
"status": "COMPLETED",
"credits_added": 1000 // if credit purchase
}
"""
account = request.user.account
order_id = request.data.get('order_id')
package_id = request.data.get('package_id')
plan_id = request.data.get('plan_id')
if not order_id:
return error_response(
error='order_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
try:
service = PayPalService()
# Capture the order
capture_result = service.capture_order(order_id)
if capture_result.get('status') != 'COMPLETED':
return error_response(
error=f"Payment not completed: {capture_result.get('status')}",
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Verify the custom_id matches our account
custom_id = capture_result.get('custom_id')
if custom_id and str(custom_id) != str(account.id):
logger.warning(
f"PayPal capture account mismatch: expected {account.id}, got {custom_id}"
)
# Process based on payment type
if package_id:
result = _process_credit_purchase(
account=account,
package_id=package_id,
capture_result=capture_result,
)
elif plan_id:
result = _process_subscription_payment(
account=account,
plan_id=plan_id,
capture_result=capture_result,
)
else:
# Generic payment - just record it
result = _process_generic_payment(
account=account,
capture_result=capture_result,
)
return success_response(
data={
**capture_result,
**result,
},
message='Payment captured successfully',
request=request
)
except PayPalConfigurationError as e:
logger.error(f"PayPal configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except PayPalAPIError as e:
logger.error(f"PayPal API error during capture: {e}")
return error_response(
error=f'Payment capture failed: {str(e)}',
status_code=status.HTTP_502_BAD_GATEWAY,
request=request
)
except Exception as e:
logger.exception(f"PayPal capture order error: {e}")
return error_response(
error='Failed to capture payment',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class PayPalCreateSubscriptionView(APIView):
"""Create PayPal recurring subscription"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Create PayPal subscription for recurring billing.
Requires plan to have paypal_plan_id configured (created in PayPal dashboard).
Request body:
{
"plan_id": "our plan UUID",
"return_url": "optional",
"cancel_url": "optional"
}
Returns:
{
"subscription_id": "I-...",
"approval_url": "https://www.paypal.com/...",
"status": "APPROVAL_PENDING"
}
"""
account = request.user.account
plan_id = request.data.get('plan_id')
if not plan_id:
return error_response(
error='plan_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get plan
try:
plan = Plan.objects.get(id=plan_id, is_active=True)
except Plan.DoesNotExist:
return error_response(
error='Plan not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Check for PayPal plan ID
paypal_plan_id = getattr(plan, 'paypal_plan_id', None)
if not paypal_plan_id:
return error_response(
error='Plan is not configured for PayPal subscriptions',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
try:
service = PayPalService()
return_url = request.data.get('return_url')
cancel_url = request.data.get('cancel_url')
subscription = service.create_subscription(
account=account,
plan_id=paypal_plan_id,
return_url=return_url,
cancel_url=cancel_url,
)
return success_response(
data=subscription,
message='PayPal subscription created',
request=request
)
except PayPalConfigurationError as e:
logger.error(f"PayPal configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except PayPalAPIError as e:
logger.error(f"PayPal API error: {e}")
return error_response(
error=f'PayPal error: {str(e)}',
status_code=status.HTTP_502_BAD_GATEWAY,
request=request
)
except Exception as e:
logger.exception(f"PayPal create subscription error: {e}")
return error_response(
error='Failed to create PayPal subscription',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@csrf_exempt
@api_view(['POST'])
@permission_classes([AllowAny])
def paypal_webhook(request):
"""
Handle PayPal webhook events.
Events handled:
- CHECKOUT.ORDER.APPROVED - Order approved by customer
- PAYMENT.CAPTURE.COMPLETED - Payment captured successfully
- PAYMENT.CAPTURE.DENIED - Payment capture failed
- BILLING.SUBSCRIPTION.ACTIVATED - Subscription activated
- BILLING.SUBSCRIPTION.CANCELLED - Subscription cancelled
- BILLING.SUBSCRIPTION.SUSPENDED - Subscription suspended
- BILLING.SUBSCRIPTION.PAYMENT.FAILED - Subscription payment failed
"""
try:
# Parse body
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return Response(
{'error': 'Invalid JSON'},
status=status.HTTP_400_BAD_REQUEST
)
# Get headers for verification
headers = {
'PAYPAL-AUTH-ALGO': request.META.get('HTTP_PAYPAL_AUTH_ALGO', ''),
'PAYPAL-CERT-URL': request.META.get('HTTP_PAYPAL_CERT_URL', ''),
'PAYPAL-TRANSMISSION-ID': request.META.get('HTTP_PAYPAL_TRANSMISSION_ID', ''),
'PAYPAL-TRANSMISSION-SIG': request.META.get('HTTP_PAYPAL_TRANSMISSION_SIG', ''),
'PAYPAL-TRANSMISSION-TIME': request.META.get('HTTP_PAYPAL_TRANSMISSION_TIME', ''),
}
# Verify webhook signature
try:
service = PayPalService()
is_valid = service.verify_webhook_signature(headers, body)
if not is_valid:
logger.warning("PayPal webhook signature verification failed")
# Optionally reject invalid signatures
# return Response({'error': 'Invalid signature'}, status=400)
except PayPalConfigurationError:
logger.warning("PayPal not configured for webhook verification")
except Exception as e:
logger.error(f"PayPal webhook verification error: {e}")
# Process event
event_type = body.get('event_type', '')
resource = body.get('resource', {})
logger.info(f"PayPal webhook received: {event_type}")
if event_type == 'CHECKOUT.ORDER.APPROVED':
_handle_order_approved(resource)
elif event_type == 'PAYMENT.CAPTURE.COMPLETED':
_handle_capture_completed(resource)
elif event_type == 'PAYMENT.CAPTURE.DENIED':
_handle_capture_denied(resource)
elif event_type == 'BILLING.SUBSCRIPTION.ACTIVATED':
_handle_subscription_activated(resource)
elif event_type == 'BILLING.SUBSCRIPTION.CANCELLED':
_handle_subscription_cancelled(resource)
elif event_type == 'BILLING.SUBSCRIPTION.SUSPENDED':
_handle_subscription_suspended(resource)
elif event_type == 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
_handle_subscription_payment_failed(resource)
else:
logger.info(f"Unhandled PayPal event type: {event_type}")
return Response({'status': 'success'})
except Exception as e:
logger.exception(f"Error processing PayPal webhook: {e}")
return Response({'status': 'error', 'message': str(e)})
# ========== Helper Functions ==========
def _process_credit_purchase(account, package_id: str, capture_result: dict) -> dict:
"""Process credit package purchase after capture"""
try:
package = CreditPackage.objects.get(id=package_id)
except CreditPackage.DoesNotExist:
logger.error(f"Credit package {package_id} not found for PayPal capture")
return {'error': 'Package not found'}
with transaction.atomic():
# Create invoice
invoice = InvoiceService.create_credit_package_invoice(
account=account,
credit_package=package,
)
# Mark invoice as paid
InvoiceService.mark_paid(
invoice=invoice,
payment_method='paypal',
transaction_id=capture_result.get('capture_id')
)
# Create payment record
amount = float(capture_result.get('amount', package.price))
currency = capture_result.get('currency', 'USD')
payment = Payment.objects.create(
account=account,
invoice=invoice,
amount=amount,
currency=currency,
payment_method='paypal',
status='succeeded',
paypal_order_id=capture_result.get('order_id'),
paypal_capture_id=capture_result.get('capture_id'),
processed_at=timezone.now(),
metadata={
'credit_package_id': str(package_id),
'credits_added': package.credits,
}
)
# Add credits
CreditService.add_credits(
account=account,
amount=package.credits,
transaction_type='purchase',
description=f'Credit package: {package.name} ({package.credits} credits)',
metadata={
'payment_id': payment.id,
'package_id': str(package_id),
'payment_method': 'paypal',
}
)
logger.info(
f"PayPal credit purchase completed for account {account.id}: "
f"package={package.name}, credits={package.credits}"
)
return {
'credits_added': package.credits,
'new_balance': account.credits,
}
def _process_subscription_payment(account, plan_id: str, capture_result: dict) -> dict:
"""Process subscription payment after capture"""
try:
plan = Plan.objects.get(id=plan_id)
except Plan.DoesNotExist:
logger.error(f"Plan {plan_id} not found for PayPal subscription payment")
return {'error': 'Plan not found'}
with transaction.atomic():
# Create or update subscription
now = timezone.now()
period_end = now + timezone.timedelta(days=30) # Monthly
subscription, created = Subscription.objects.update_or_create(
account=account,
defaults={
'plan': plan,
'status': 'active',
'current_period_start': now,
'current_period_end': period_end,
'external_payment_id': capture_result.get('order_id'),
}
)
# Create invoice
invoice = InvoiceService.create_subscription_invoice(
account=account,
plan=plan,
billing_period_start=now,
billing_period_end=period_end,
)
# Mark invoice as paid
InvoiceService.mark_paid(
invoice=invoice,
payment_method='paypal',
transaction_id=capture_result.get('capture_id')
)
# Create payment record
amount = float(capture_result.get('amount', plan.price))
currency = capture_result.get('currency', 'USD')
payment = Payment.objects.create(
account=account,
invoice=invoice,
amount=amount,
currency=currency,
payment_method='paypal',
status='succeeded',
paypal_order_id=capture_result.get('order_id'),
paypal_capture_id=capture_result.get('capture_id'),
processed_at=timezone.now(),
metadata={
'plan_id': str(plan_id),
'subscription_type': 'paypal_order',
}
)
# Add subscription credits
if plan.included_credits and plan.included_credits > 0:
CreditService.add_credits(
account=account,
amount=plan.included_credits,
transaction_type='subscription',
description=f'Subscription: {plan.name}',
metadata={
'plan_id': str(plan.id),
'payment_method': 'paypal',
}
)
# Update account status
if account.status != 'active':
account.status = 'active'
account.save(update_fields=['status', 'updated_at'])
logger.info(
f"PayPal subscription payment completed for account {account.id}: "
f"plan={plan.name}"
)
return {
'subscription_id': subscription.id,
'plan_name': plan.name,
'credits_added': plan.included_credits or 0,
}
def _process_generic_payment(account, capture_result: dict) -> dict:
"""Process generic PayPal payment"""
amount = float(capture_result.get('amount', 0))
currency = capture_result.get('currency', 'USD')
with transaction.atomic():
payment = Payment.objects.create(
account=account,
amount=amount,
currency=currency,
payment_method='paypal',
status='succeeded',
paypal_order_id=capture_result.get('order_id'),
paypal_capture_id=capture_result.get('capture_id'),
processed_at=timezone.now(),
)
logger.info(f"PayPal generic payment recorded for account {account.id}")
return {'payment_id': payment.id}
# ========== Webhook Event Handlers ==========
def _handle_order_approved(resource: dict):
"""Handle order approved event (user clicked approve on PayPal)"""
order_id = resource.get('id')
logger.info(f"PayPal order approved: {order_id}")
# The frontend will call capture-order after this
def _handle_capture_completed(resource: dict):
"""Handle payment capture completed webhook"""
capture_id = resource.get('id')
custom_id = resource.get('custom_id') # Our account ID
logger.info(f"PayPal capture completed: {capture_id}, account={custom_id}")
# This is a backup - the frontend capture call should have already processed this
def _handle_capture_denied(resource: dict):
"""Handle payment capture denied"""
capture_id = resource.get('id')
custom_id = resource.get('custom_id')
logger.warning(f"PayPal capture denied: {capture_id}, account={custom_id}")
# Mark any pending payments as failed
if custom_id:
try:
Payment.objects.filter(
account_id=custom_id,
payment_method='paypal',
status='pending'
).update(
status='failed',
failure_reason='Payment capture denied by PayPal'
)
except Exception as e:
logger.error(f"Error updating denied payment: {e}")
def _handle_subscription_activated(resource: dict):
"""Handle PayPal subscription activation"""
subscription_id = resource.get('id')
custom_id = resource.get('custom_id')
plan_id = resource.get('plan_id')
logger.info(
f"PayPal subscription activated: {subscription_id}, account={custom_id}"
)
if not custom_id:
return
try:
account = Account.objects.get(id=custom_id)
# Find matching plan by PayPal plan ID
plan = Plan.objects.filter(paypal_plan_id=plan_id).first()
if not plan:
logger.warning(f"No plan found with paypal_plan_id={plan_id}")
return
# Create/update subscription
now = timezone.now()
Subscription.objects.update_or_create(
account=account,
defaults={
'plan': plan,
'status': 'active',
'external_payment_id': subscription_id,
'current_period_start': now,
'current_period_end': now + timezone.timedelta(days=30),
}
)
# Add credits
if plan.included_credits:
CreditService.add_credits(
account=account,
amount=plan.included_credits,
transaction_type='subscription',
description=f'PayPal Subscription: {plan.name}',
)
# Activate account
if account.status != 'active':
account.status = 'active'
account.save(update_fields=['status', 'updated_at'])
except Account.DoesNotExist:
logger.error(f"Account {custom_id} not found for PayPal subscription activation")
def _handle_subscription_cancelled(resource: dict):
"""Handle PayPal subscription cancellation"""
subscription_id = resource.get('id')
custom_id = resource.get('custom_id')
logger.info(f"PayPal subscription cancelled: {subscription_id}")
if custom_id:
try:
subscription = Subscription.objects.get(
account_id=custom_id,
external_payment_id=subscription_id
)
subscription.status = 'canceled'
subscription.save(update_fields=['status', 'updated_at'])
except Subscription.DoesNotExist:
pass
def _handle_subscription_suspended(resource: dict):
"""Handle PayPal subscription suspension"""
subscription_id = resource.get('id')
custom_id = resource.get('custom_id')
logger.info(f"PayPal subscription suspended: {subscription_id}")
if custom_id:
try:
subscription = Subscription.objects.get(
account_id=custom_id,
external_payment_id=subscription_id
)
subscription.status = 'past_due'
subscription.save(update_fields=['status', 'updated_at'])
except Subscription.DoesNotExist:
pass
def _handle_subscription_payment_failed(resource: dict):
"""Handle PayPal subscription payment failure"""
subscription_id = resource.get('id')
custom_id = resource.get('custom_id')
logger.warning(f"PayPal subscription payment failed: {subscription_id}")
if custom_id:
try:
subscription = Subscription.objects.get(
account_id=custom_id,
external_payment_id=subscription_id
)
subscription.status = 'past_due'
subscription.save(update_fields=['status', 'updated_at'])
# TODO: Send payment failure notification email
except Subscription.DoesNotExist:
pass

View File

@@ -0,0 +1,701 @@
"""
Stripe Views - Checkout, Portal, and Webhook endpoints
Endpoints:
- POST /billing/stripe/checkout/ - Create checkout session for subscription
- POST /billing/stripe/credit-checkout/ - Create checkout session for credit package
- POST /billing/stripe/billing-portal/ - Create billing portal session
- POST /billing/webhooks/stripe/ - Handle Stripe webhooks
- GET /billing/stripe/config/ - Get Stripe publishable key
"""
import stripe
import logging
from datetime import datetime
from django.conf import settings
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from rest_framework.views import APIView
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from igny8_core.api.response import success_response, error_response
from igny8_core.api.permissions import IsAuthenticatedAndActive
from igny8_core.auth.models import Plan, Account, Subscription
from ..models import CreditPackage, Payment, Invoice, CreditTransaction
from ..services.stripe_service import StripeService, StripeConfigurationError
from ..services.payment_service import PaymentService
from ..services.invoice_service import InvoiceService
from ..services.credit_service import CreditService
logger = logging.getLogger(__name__)
class StripeConfigView(APIView):
"""Return Stripe publishable key for frontend initialization"""
permission_classes = [AllowAny]
def get(self, request):
"""Get Stripe publishable key"""
try:
service = StripeService()
return Response({
'publishable_key': service.get_publishable_key(),
'is_sandbox': service.is_sandbox,
})
except StripeConfigurationError as e:
return Response(
{'error': str(e), 'configured': False},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
class StripeCheckoutView(APIView):
"""Create Stripe Checkout session for subscription"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Create checkout session for plan subscription.
Request body:
{
"plan_id": "uuid",
"success_url": "optional",
"cancel_url": "optional"
}
Returns:
{
"checkout_url": "https://checkout.stripe.com/...",
"session_id": "cs_..."
}
"""
account = request.user.account
plan_id = request.data.get('plan_id')
if not plan_id:
return error_response(
error='plan_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get plan
try:
plan = Plan.objects.get(id=plan_id, is_active=True)
except Plan.DoesNotExist:
return error_response(
error='Plan not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Validate plan has Stripe configuration
if not plan.stripe_price_id:
return error_response(
error='Plan is not configured for Stripe payments',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
try:
service = StripeService()
# Get optional URLs
success_url = request.data.get('success_url')
cancel_url = request.data.get('cancel_url')
session = service.create_checkout_session(
account=account,
plan=plan,
success_url=success_url,
cancel_url=cancel_url,
)
return success_response(
data=session,
message='Checkout session created',
request=request
)
except StripeConfigurationError as e:
logger.error(f"Stripe configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except Exception as e:
logger.exception(f"Stripe checkout error: {e}")
return error_response(
error='Failed to create checkout session',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class StripeCreditCheckoutView(APIView):
"""Create Stripe Checkout session for credit package purchase"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Create checkout session for credit package purchase.
Request body:
{
"package_id": "uuid",
"success_url": "optional",
"cancel_url": "optional"
}
Returns:
{
"checkout_url": "https://checkout.stripe.com/...",
"session_id": "cs_..."
}
"""
account = request.user.account
package_id = request.data.get('package_id')
if not package_id:
return error_response(
error='package_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get credit package
try:
package = CreditPackage.objects.get(id=package_id, is_active=True)
except CreditPackage.DoesNotExist:
return error_response(
error='Credit package not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
try:
service = StripeService()
# Get optional URLs
success_url = request.data.get('success_url')
cancel_url = request.data.get('cancel_url')
session = service.create_credit_checkout_session(
account=account,
credit_package=package,
success_url=success_url,
cancel_url=cancel_url,
)
return success_response(
data=session,
message='Credit checkout session created',
request=request
)
except StripeConfigurationError as e:
logger.error(f"Stripe configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except Exception as e:
logger.exception(f"Stripe credit checkout error: {e}")
return error_response(
error='Failed to create checkout session',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class StripeBillingPortalView(APIView):
"""Create Stripe Billing Portal session for subscription management"""
permission_classes = [IsAuthenticatedAndActive]
def post(self, request):
"""
Create billing portal session for subscription management.
Request body:
{
"return_url": "optional"
}
Returns:
{
"portal_url": "https://billing.stripe.com/..."
}
"""
account = request.user.account
try:
service = StripeService()
return_url = request.data.get('return_url')
session = service.create_billing_portal_session(
account=account,
return_url=return_url,
)
return success_response(
data=session,
message='Billing portal session created',
request=request
)
except StripeConfigurationError as e:
logger.error(f"Stripe configuration error: {e}")
return error_response(
error='Payment system not configured',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except ValueError as e:
return error_response(
error=str(e),
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
except Exception as e:
logger.exception(f"Stripe billing portal error: {e}")
return error_response(
error='Failed to create billing portal session',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@csrf_exempt
@api_view(['POST'])
@permission_classes([AllowAny])
def stripe_webhook(request):
"""
Handle Stripe webhook events.
Events handled:
- checkout.session.completed: New subscription or credit purchase
- invoice.paid: Recurring payment success
- invoice.payment_failed: Payment failure
- customer.subscription.updated: Plan changes
- customer.subscription.deleted: Cancellation
"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
if not sig_header:
logger.warning("Stripe webhook received without signature")
return Response(
{'error': 'Missing Stripe signature'},
status=status.HTTP_400_BAD_REQUEST
)
try:
service = StripeService()
event = service.construct_webhook_event(payload, sig_header)
except StripeConfigurationError as e:
logger.error(f"Stripe webhook configuration error: {e}")
return Response(
{'error': 'Webhook not configured'},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
except stripe.error.SignatureVerificationError as e:
logger.warning(f"Stripe webhook signature verification failed: {e}")
return Response(
{'error': 'Invalid signature'},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
logger.error(f"Stripe webhook error: {e}")
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
event_type = event['type']
data = event['data']['object']
logger.info(f"Stripe webhook received: {event_type}")
try:
if event_type == 'checkout.session.completed':
_handle_checkout_completed(data)
elif event_type == 'invoice.paid':
_handle_invoice_paid(data)
elif event_type == 'invoice.payment_failed':
_handle_payment_failed(data)
elif event_type == 'customer.subscription.updated':
_handle_subscription_updated(data)
elif event_type == 'customer.subscription.deleted':
_handle_subscription_deleted(data)
else:
logger.info(f"Unhandled Stripe event type: {event_type}")
return Response({'status': 'success'})
except Exception as e:
logger.exception(f"Error processing Stripe webhook {event_type}: {e}")
# Return 200 to prevent Stripe retries for application errors
# Log the error for debugging
return Response({'status': 'error', 'message': str(e)})
# ========== Webhook Event Handlers ==========
def _handle_checkout_completed(session: dict):
"""
Handle successful checkout session.
Processes both subscription and one-time credit purchases.
"""
metadata = session.get('metadata', {})
account_id = metadata.get('account_id')
payment_type = metadata.get('type', '')
if not account_id:
logger.error('No account_id in checkout session metadata')
return
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
logger.error(f'Account {account_id} not found for checkout session')
return
mode = session.get('mode')
if mode == 'subscription':
# Handle new subscription
subscription_id = session.get('subscription')
plan_id = metadata.get('plan_id')
_activate_subscription(account, subscription_id, plan_id, session)
elif mode == 'payment':
# Handle one-time payment (credit purchase)
credit_package_id = metadata.get('credit_package_id')
credit_amount = metadata.get('credit_amount')
if credit_package_id:
_add_purchased_credits(account, credit_package_id, credit_amount, session)
else:
logger.warning(f"Payment checkout without credit_package_id: {session.get('id')}")
def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, session: dict):
"""
Activate subscription after successful checkout.
Creates or updates Subscription record and adds initial credits.
"""
import stripe
from django.db import transaction
if not stripe_subscription_id:
logger.error("No subscription ID in checkout session")
return
# Get subscription details from Stripe
try:
stripe_sub = stripe.Subscription.retrieve(stripe_subscription_id)
except Exception as e:
logger.error(f"Failed to retrieve Stripe subscription {stripe_subscription_id}: {e}")
return
# Get plan
try:
plan = Plan.objects.get(id=plan_id)
except Plan.DoesNotExist:
logger.error(f"Plan {plan_id} not found for subscription activation")
return
with transaction.atomic():
# Create or update subscription
subscription, created = Subscription.objects.update_or_create(
account=account,
defaults={
'plan': plan,
'stripe_subscription_id': stripe_subscription_id,
'status': 'active',
'current_period_start': datetime.fromtimestamp(
stripe_sub.current_period_start,
tz=timezone.utc
),
'current_period_end': datetime.fromtimestamp(
stripe_sub.current_period_end,
tz=timezone.utc
),
'cancel_at_period_end': stripe_sub.cancel_at_period_end,
}
)
# Create invoice record
amount = session.get('amount_total', 0) / 100 # Convert from cents
currency = session.get('currency', 'usd').upper()
invoice = InvoiceService.create_subscription_invoice(
account=account,
plan=plan,
billing_period_start=subscription.current_period_start,
billing_period_end=subscription.current_period_end,
)
# Mark invoice as paid
InvoiceService.mark_paid(
invoice=invoice,
payment_method='stripe',
transaction_id=stripe_subscription_id
)
# Create payment record
Payment.objects.create(
account=account,
invoice=invoice,
amount=amount,
currency=currency,
payment_method='stripe',
status='succeeded',
stripe_payment_intent_id=session.get('payment_intent'),
processed_at=timezone.now(),
metadata={
'checkout_session_id': session.get('id'),
'subscription_id': stripe_subscription_id,
'plan_id': str(plan_id),
}
)
# Add initial credits from plan
if plan.included_credits and plan.included_credits > 0:
CreditService.add_credits(
account=account,
amount=plan.included_credits,
transaction_type='subscription',
description=f'Subscription activated: {plan.name}',
metadata={
'plan_id': str(plan.id),
'subscription_id': stripe_subscription_id,
}
)
# Update account status if needed
if account.status != 'active':
account.status = 'active'
account.save(update_fields=['status', 'updated_at'])
logger.info(
f"Subscription activated for account {account.id}: "
f"plan={plan.name}, credits={plan.included_credits}"
)
def _add_purchased_credits(account, credit_package_id: str, credit_amount: str, session: dict):
"""
Add purchased credits to account.
Creates invoice, payment, and credit transaction records.
"""
from django.db import transaction
# Get credit package
try:
package = CreditPackage.objects.get(id=credit_package_id)
except CreditPackage.DoesNotExist:
logger.error(f"Credit package {credit_package_id} not found")
return
credits_to_add = package.credits
if credit_amount:
try:
credits_to_add = int(credit_amount)
except ValueError:
pass
with transaction.atomic():
# Create invoice
invoice = InvoiceService.create_credit_package_invoice(
account=account,
credit_package=package,
)
# Mark invoice as paid
InvoiceService.mark_paid(
invoice=invoice,
payment_method='stripe',
transaction_id=session.get('payment_intent')
)
# Create payment record
amount = session.get('amount_total', 0) / 100
currency = session.get('currency', 'usd').upper()
payment = Payment.objects.create(
account=account,
invoice=invoice,
amount=amount,
currency=currency,
payment_method='stripe',
status='succeeded',
stripe_payment_intent_id=session.get('payment_intent'),
processed_at=timezone.now(),
metadata={
'checkout_session_id': session.get('id'),
'credit_package_id': str(credit_package_id),
'credits_added': credits_to_add,
}
)
# Add credits
CreditService.add_credits(
account=account,
amount=credits_to_add,
transaction_type='purchase',
description=f'Credit package: {package.name} ({credits_to_add} credits)',
metadata={
'payment_id': payment.id,
'package_id': str(credit_package_id),
}
)
logger.info(
f"Credits purchased for account {account.id}: "
f"package={package.name}, credits={credits_to_add}"
)
def _handle_invoice_paid(invoice: dict):
"""
Handle successful recurring payment.
Adds monthly credits for subscription renewal.
"""
subscription_id = invoice.get('subscription')
if not subscription_id:
return
try:
subscription = Subscription.objects.select_related(
'account', 'plan'
).get(stripe_subscription_id=subscription_id)
except Subscription.DoesNotExist:
logger.warning(f"Subscription not found for invoice.paid: {subscription_id}")
return
account = subscription.account
plan = subscription.plan
# Skip if this is the initial invoice (already handled in checkout)
billing_reason = invoice.get('billing_reason')
if billing_reason == 'subscription_create':
logger.info(f"Skipping initial invoice for subscription {subscription_id}")
return
# Add monthly credits for renewal
if plan.included_credits and plan.included_credits > 0:
CreditService.add_credits(
account=account,
amount=plan.included_credits,
transaction_type='subscription',
description=f'Monthly renewal: {plan.name}',
metadata={
'plan_id': str(plan.id),
'stripe_invoice_id': invoice.get('id'),
}
)
logger.info(
f"Renewal credits added for account {account.id}: "
f"plan={plan.name}, credits={plan.included_credits}"
)
def _handle_payment_failed(invoice: dict):
"""
Handle failed payment.
Updates subscription status to past_due.
"""
subscription_id = invoice.get('subscription')
if not subscription_id:
return
try:
subscription = Subscription.objects.get(stripe_subscription_id=subscription_id)
subscription.status = 'past_due'
subscription.save(update_fields=['status', 'updated_at'])
logger.info(f"Subscription {subscription_id} marked as past_due due to payment failure")
# TODO: Send payment failure notification email
except Subscription.DoesNotExist:
logger.warning(f"Subscription not found for payment failure: {subscription_id}")
def _handle_subscription_updated(subscription_data: dict):
"""
Handle subscription update (plan change, status change).
"""
subscription_id = subscription_data.get('id')
try:
subscription = Subscription.objects.get(stripe_subscription_id=subscription_id)
# Map Stripe status to our status
status_map = {
'active': 'active',
'past_due': 'past_due',
'canceled': 'canceled',
'unpaid': 'past_due',
'incomplete': 'pending_payment',
'incomplete_expired': 'canceled',
'trialing': 'trialing',
'paused': 'canceled',
}
stripe_status = subscription_data.get('status')
new_status = status_map.get(stripe_status, 'active')
# Update period dates
subscription.current_period_start = datetime.fromtimestamp(
subscription_data.get('current_period_start'),
tz=timezone.utc
)
subscription.current_period_end = datetime.fromtimestamp(
subscription_data.get('current_period_end'),
tz=timezone.utc
)
subscription.cancel_at_period_end = subscription_data.get('cancel_at_period_end', False)
subscription.status = new_status
subscription.save()
logger.info(f"Subscription {subscription_id} updated: status={new_status}")
except Subscription.DoesNotExist:
logger.warning(f"Subscription not found for update: {subscription_id}")
def _handle_subscription_deleted(subscription_data: dict):
"""
Handle subscription cancellation/deletion.
"""
subscription_id = subscription_data.get('id')
try:
subscription = Subscription.objects.get(stripe_subscription_id=subscription_id)
subscription.status = 'canceled'
subscription.save(update_fields=['status', 'updated_at'])
logger.info(f"Subscription {subscription_id} canceled")
# Optionally update account status
account = subscription.account
if account.status == 'active':
# Don't immediately deactivate - let them use until period end
pass
except Subscription.DoesNotExist:
logger.warning(f"Subscription not found for deletion: {subscription_id}")