Phase 3 & Phase 4 - Completed
This commit is contained in:
868
backend/igny8_core/business/billing/views/paypal_views.py
Normal file
868
backend/igny8_core/business/billing/views/paypal_views.py
Normal 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
|
||||
701
backend/igny8_core/business/billing/views/stripe_views.py
Normal file
701
backend/igny8_core/business/billing/views/stripe_views.py
Normal 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}")
|
||||
Reference in New Issue
Block a user