Phase 3 & Phase 4 - Completed
This commit is contained in:
@@ -1,228 +1,767 @@
|
||||
"""
|
||||
Email service for billing notifications
|
||||
Email Service - Multi-provider email sending
|
||||
|
||||
Uses Resend for transactional emails with fallback to Django's send_mail.
|
||||
Supports template rendering and multiple email types.
|
||||
|
||||
Configuration stored in IntegrationProvider model (provider_id='resend')
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import resend - it's optional
|
||||
try:
|
||||
import resend
|
||||
RESEND_AVAILABLE = True
|
||||
except ImportError:
|
||||
RESEND_AVAILABLE = False
|
||||
logger.info("Resend package not installed, will use Django mail backend")
|
||||
|
||||
|
||||
class EmailConfigurationError(Exception):
|
||||
"""Raised when email provider is not properly configured"""
|
||||
pass
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""
|
||||
Unified email service supporting multiple providers.
|
||||
|
||||
Primary: Resend (for production transactional emails)
|
||||
Fallback: Django's send_mail (uses EMAIL_BACKEND from settings)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._resend_configured = False
|
||||
self._resend_config = {}
|
||||
self._brevo_configured = False
|
||||
self._brevo_config = {}
|
||||
self._setup_providers()
|
||||
|
||||
def _setup_providers(self):
|
||||
"""Initialize email providers from IntegrationProvider"""
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
|
||||
# Setup Resend
|
||||
if RESEND_AVAILABLE:
|
||||
resend_provider = IntegrationProvider.get_provider('resend')
|
||||
if resend_provider and resend_provider.api_key:
|
||||
resend.api_key = resend_provider.api_key
|
||||
self._resend_config = resend_provider.config or {}
|
||||
self._resend_configured = True
|
||||
logger.info("Resend email provider initialized")
|
||||
else:
|
||||
logger.info("Resend provider not configured in IntegrationProvider")
|
||||
|
||||
# Setup Brevo (future - for marketing emails)
|
||||
brevo_provider = IntegrationProvider.get_provider('brevo')
|
||||
if brevo_provider and brevo_provider.api_key:
|
||||
self._brevo_config = brevo_provider.config or {}
|
||||
self._brevo_configured = True
|
||||
logger.info("Brevo email provider initialized")
|
||||
|
||||
@property
|
||||
def from_email(self) -> str:
|
||||
"""Get default from email"""
|
||||
return self._resend_config.get(
|
||||
'from_email',
|
||||
getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com')
|
||||
)
|
||||
|
||||
@property
|
||||
def from_name(self) -> str:
|
||||
"""Get default from name"""
|
||||
return self._resend_config.get('from_name', 'IGNY8')
|
||||
|
||||
@property
|
||||
def reply_to(self) -> str:
|
||||
"""Get default reply-to address"""
|
||||
return self._resend_config.get('reply_to', 'support@igny8.com')
|
||||
|
||||
def send_transactional(
|
||||
self,
|
||||
to: str | List[str],
|
||||
subject: str,
|
||||
html: Optional[str] = None,
|
||||
text: Optional[str] = None,
|
||||
template: Optional[str] = None,
|
||||
context: Optional[Dict] = None,
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send transactional email via Resend or fallback.
|
||||
|
||||
Args:
|
||||
to: Recipient email(s) - string or list
|
||||
subject: Email subject line
|
||||
html: HTML content (or use template)
|
||||
text: Plain text content (optional if html provided)
|
||||
template: Django template path (e.g., 'emails/welcome.html')
|
||||
context: Template context dictionary
|
||||
from_email: Override sender email
|
||||
from_name: Override sender name
|
||||
reply_to: Reply-to address
|
||||
attachments: List of attachments [{'filename': 'x', 'content': bytes}]
|
||||
tags: Tags for email analytics
|
||||
|
||||
Returns:
|
||||
dict: Response with 'id' (message ID) or 'error'
|
||||
"""
|
||||
# Ensure to is a list
|
||||
if isinstance(to, str):
|
||||
to = [to]
|
||||
|
||||
# Render template if provided
|
||||
if template:
|
||||
try:
|
||||
html = render_to_string(template, context or {})
|
||||
except TemplateDoesNotExist:
|
||||
logger.warning(f"Email template not found: {template}")
|
||||
# Try without .html extension or with alternative path
|
||||
pass
|
||||
|
||||
if not html and not text:
|
||||
raise ValueError("Either html, text, or template must be provided")
|
||||
|
||||
# Build from address
|
||||
sender_name = from_name or self.from_name
|
||||
sender_email = from_email or self.from_email
|
||||
from_address = f"{sender_name} <{sender_email}>"
|
||||
|
||||
# Try Resend first
|
||||
if self._resend_configured:
|
||||
return self._send_via_resend(
|
||||
to=to,
|
||||
subject=subject,
|
||||
html=html,
|
||||
text=text,
|
||||
from_address=from_address,
|
||||
reply_to=reply_to or self.reply_to,
|
||||
attachments=attachments,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Fallback to Django mail
|
||||
return self._send_via_django(
|
||||
to=to,
|
||||
subject=subject,
|
||||
html=html,
|
||||
text=text,
|
||||
from_email=sender_email,
|
||||
)
|
||||
|
||||
def _send_via_resend(
|
||||
self,
|
||||
to: List[str],
|
||||
subject: str,
|
||||
html: Optional[str],
|
||||
text: Optional[str],
|
||||
from_address: str,
|
||||
reply_to: Optional[str],
|
||||
attachments: Optional[List[Dict]],
|
||||
tags: Optional[List[str]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Send email via Resend API"""
|
||||
try:
|
||||
params: Dict[str, Any] = {
|
||||
'from': from_address,
|
||||
'to': to,
|
||||
'subject': subject,
|
||||
}
|
||||
|
||||
if html:
|
||||
params['html'] = html
|
||||
if text:
|
||||
params['text'] = text
|
||||
if reply_to:
|
||||
params['reply_to'] = reply_to
|
||||
if tags:
|
||||
params['tags'] = [{'name': tag} for tag in tags]
|
||||
if attachments:
|
||||
params['attachments'] = attachments
|
||||
|
||||
response = resend.Emails.send(params)
|
||||
|
||||
logger.info(f"Email sent via Resend: {subject} to {to}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'id': response.get('id'),
|
||||
'provider': 'resend',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email via Resend: {str(e)}")
|
||||
|
||||
# Fallback to Django
|
||||
logger.info("Falling back to Django mail backend")
|
||||
return self._send_via_django(
|
||||
to=to,
|
||||
subject=subject,
|
||||
html=html,
|
||||
text=text,
|
||||
from_email=from_address.split('<')[-1].rstrip('>'),
|
||||
)
|
||||
|
||||
def _send_via_django(
|
||||
self,
|
||||
to: List[str],
|
||||
subject: str,
|
||||
html: Optional[str],
|
||||
text: Optional[str],
|
||||
from_email: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send email via Django's send_mail"""
|
||||
try:
|
||||
# Use text content or strip HTML
|
||||
message = text
|
||||
if not message and html:
|
||||
# Basic HTML to text conversion
|
||||
import re
|
||||
message = re.sub(r'<[^>]+>', '', html)
|
||||
message = message.strip()
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message or '',
|
||||
from_email=from_email,
|
||||
recipient_list=to,
|
||||
html_message=html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Email sent via Django: {subject} to {to}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'id': None,
|
||||
'provider': 'django',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email via Django: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'provider': 'django',
|
||||
}
|
||||
|
||||
|
||||
# ========== Singleton Instance ==========
|
||||
|
||||
_email_service: Optional[EmailService] = None
|
||||
|
||||
|
||||
def get_email_service() -> EmailService:
|
||||
"""Get singleton EmailService instance"""
|
||||
global _email_service
|
||||
if _email_service is None:
|
||||
_email_service = EmailService()
|
||||
return _email_service
|
||||
|
||||
|
||||
# ========== Billing Email Service (Legacy Interface) ==========
|
||||
|
||||
class BillingEmailService:
|
||||
"""Service for sending billing-related emails"""
|
||||
|
||||
"""
|
||||
Service for sending billing-related emails.
|
||||
|
||||
This class provides specific methods for different billing events
|
||||
while using the unified EmailService internally.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_frontend_url() -> str:
|
||||
"""Get frontend URL from settings"""
|
||||
return getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
@staticmethod
|
||||
def send_payment_confirmation_email(payment, account):
|
||||
"""
|
||||
Send email when user submits manual payment for approval
|
||||
Send email when user submits manual payment for approval.
|
||||
"""
|
||||
subject = f'Payment Confirmation Received - Invoice #{payment.invoice.invoice_number}'
|
||||
|
||||
service = get_email_service()
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'invoice_number': payment.invoice.invoice_number,
|
||||
'invoice_number': payment.invoice.invoice_number if payment.invoice else 'N/A',
|
||||
'amount': payment.amount,
|
||||
'currency': payment.currency,
|
||||
'payment_method': payment.get_payment_method_display(),
|
||||
'manual_reference': payment.manual_reference,
|
||||
'manual_reference': payment.manual_reference or payment.transaction_reference or '',
|
||||
'created_at': payment.created_at,
|
||||
'frontend_url': BillingEmailService._get_frontend_url(),
|
||||
}
|
||||
|
||||
# Plain text message
|
||||
message = f"""
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=f'Payment Confirmation Received - Invoice #{context["invoice_number"]}',
|
||||
template='emails/payment_confirmation.html',
|
||||
context=context,
|
||||
tags=['billing', 'payment-confirmation'],
|
||||
)
|
||||
logger.info(f'Payment confirmation email sent for Payment {payment.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
# Fallback to plain text
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=f'Payment Confirmation Received - Invoice #{context["invoice_number"]}',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
We have received your payment confirmation for Invoice #{payment.invoice.invoice_number}.
|
||||
We have received your payment confirmation for Invoice #{context['invoice_number']}.
|
||||
|
||||
Payment Details:
|
||||
- Amount: {payment.currency} {payment.amount}
|
||||
- Payment Method: {payment.get_payment_method_display()}
|
||||
- Reference: {payment.manual_reference}
|
||||
- Reference: {context['manual_reference']}
|
||||
- Submitted: {payment.created_at.strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
Your payment is currently under review. You will receive another email once it has been approved.
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or account.owner.email],
|
||||
fail_silently=False,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
logger.info(f'Payment confirmation email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_payment_approved_email(payment, account, subscription):
|
||||
def send_payment_approved_email(payment, account, subscription=None):
|
||||
"""
|
||||
Send email when payment is approved and account activated
|
||||
Send email when payment is approved and account activated.
|
||||
"""
|
||||
subject = f'Payment Approved - Account Activated'
|
||||
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'invoice_number': payment.invoice.invoice_number,
|
||||
'invoice_number': payment.invoice.invoice_number if payment.invoice else 'N/A',
|
||||
'amount': payment.amount,
|
||||
'currency': payment.currency,
|
||||
'plan_name': subscription.plan.name if subscription else 'N/A',
|
||||
'approved_at': payment.approved_at,
|
||||
'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A',
|
||||
'approved_at': payment.approved_at or payment.processed_at,
|
||||
'frontend_url': frontend_url,
|
||||
'dashboard_url': f'{frontend_url}/dashboard',
|
||||
}
|
||||
|
||||
message = f"""
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Payment Approved - Account Activated',
|
||||
template='emails/payment_approved.html',
|
||||
context=context,
|
||||
tags=['billing', 'payment-approved'],
|
||||
)
|
||||
logger.info(f'Payment approved email sent for Payment {payment.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment approved email: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Payment Approved - Account Activated',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
Great news! Your payment has been approved and your account is now active.
|
||||
|
||||
Payment Details:
|
||||
- Invoice: #{payment.invoice.invoice_number}
|
||||
- Invoice: #{context['invoice_number']}
|
||||
- Amount: {payment.currency} {payment.amount}
|
||||
- Plan: {subscription.plan.name if subscription else 'N/A'}
|
||||
- Approved: {payment.approved_at.strftime('%Y-%m-%d %H:%M')}
|
||||
- Plan: {context['plan_name']}
|
||||
|
||||
You can now access all features of your plan. Log in to get started!
|
||||
|
||||
Dashboard: {settings.FRONTEND_URL}/dashboard
|
||||
Dashboard: {context['dashboard_url']}
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or account.owner.email],
|
||||
fail_silently=False,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
logger.info(f'Payment approved email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment approved email: {str(e)}')
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_payment_rejected_email(payment, account, reason):
|
||||
"""
|
||||
Send email when payment is rejected
|
||||
Send email when payment is rejected.
|
||||
"""
|
||||
subject = f'Payment Declined - Action Required'
|
||||
|
||||
message = f"""
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'invoice_number': payment.invoice.invoice_number if payment.invoice else 'N/A',
|
||||
'amount': payment.amount,
|
||||
'currency': payment.currency,
|
||||
'manual_reference': payment.manual_reference or payment.transaction_reference or '',
|
||||
'reason': reason,
|
||||
'frontend_url': frontend_url,
|
||||
'billing_url': f'{frontend_url}/account/billing',
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Payment Declined - Action Required',
|
||||
template='emails/payment_rejected.html',
|
||||
context=context,
|
||||
tags=['billing', 'payment-rejected'],
|
||||
)
|
||||
logger.info(f'Payment rejected email sent for Payment {payment.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Payment Declined - Action Required',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
Unfortunately, we were unable to approve your payment for Invoice #{payment.invoice.invoice_number}.
|
||||
Unfortunately, we were unable to approve your payment for Invoice #{context['invoice_number']}.
|
||||
|
||||
Reason: {reason}
|
||||
|
||||
Payment Details:
|
||||
- Invoice: #{payment.invoice.invoice_number}
|
||||
- Invoice: #{context['invoice_number']}
|
||||
- Amount: {payment.currency} {payment.amount}
|
||||
- Reference: {payment.manual_reference}
|
||||
- Reference: {context['manual_reference']}
|
||||
|
||||
You can retry your payment by logging into your account:
|
||||
{settings.FRONTEND_URL}/billing
|
||||
{context['billing_url']}
|
||||
|
||||
If you have questions, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or account.owner.email],
|
||||
fail_silently=False,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
logger.info(f'Payment rejected email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_refund_notification(user, payment, refund_amount, reason):
|
||||
"""
|
||||
Send email when refund is processed
|
||||
Send email when refund is processed.
|
||||
"""
|
||||
subject = f'Refund Processed - Invoice #{payment.invoice.invoice_number}'
|
||||
|
||||
message = f"""
|
||||
Hi {user.first_name or user.email},
|
||||
service = get_email_service()
|
||||
|
||||
context = {
|
||||
'user_name': user.first_name or user.email,
|
||||
'invoice_number': payment.invoice.invoice_number if payment.invoice else 'N/A',
|
||||
'original_amount': payment.amount,
|
||||
'refund_amount': refund_amount,
|
||||
'currency': payment.currency,
|
||||
'reason': reason,
|
||||
'refunded_at': payment.refunded_at,
|
||||
'frontend_url': BillingEmailService._get_frontend_url(),
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=user.email,
|
||||
subject=f'Refund Processed - Invoice #{context["invoice_number"]}',
|
||||
template='emails/refund_notification.html',
|
||||
context=context,
|
||||
tags=['billing', 'refund'],
|
||||
)
|
||||
logger.info(f'Refund notification email sent for Payment {payment.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send refund notification email: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=user.email,
|
||||
subject=f'Refund Processed - Invoice #{context["invoice_number"]}',
|
||||
text=f"""
|
||||
Hi {context['user_name']},
|
||||
|
||||
Your refund has been processed successfully.
|
||||
|
||||
Refund Details:
|
||||
- Invoice: #{payment.invoice.invoice_number}
|
||||
- Original Amount: {payment.currency} {payment.amount}
|
||||
- Invoice: #{context['invoice_number']}
|
||||
- Original Amount: {payment.currency} {context['original_amount']}
|
||||
- Refund Amount: {payment.currency} {refund_amount}
|
||||
- Reason: {reason}
|
||||
- Processed: {payment.refunded_at.strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
The refund will appear in your original payment method within 5-10 business days.
|
||||
|
||||
If you have any questions, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
logger.info(f'Refund notification email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send refund notification email: {str(e)}')
|
||||
|
||||
|
||||
@staticmethod
|
||||
def send_subscription_renewal_notice(subscription, days_until_renewal):
|
||||
"""
|
||||
Send email reminder before subscription renewal
|
||||
Send email reminder before subscription renewal.
|
||||
"""
|
||||
subject = f'Subscription Renewal Reminder - {days_until_renewal} Days'
|
||||
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
account = subscription.account
|
||||
user = account.owner
|
||||
|
||||
message = f"""
|
||||
plan = subscription.plan
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'plan_name': plan.name,
|
||||
'renewal_date': subscription.current_period_end,
|
||||
'days_until_renewal': days_until_renewal,
|
||||
'amount': plan.price,
|
||||
'currency': plan.currency if hasattr(plan, 'currency') else 'USD',
|
||||
'frontend_url': frontend_url,
|
||||
'subscription_url': f'{frontend_url}/account/plans',
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=f'Subscription Renewal Reminder - {days_until_renewal} Days',
|
||||
template='emails/subscription_renewal.html',
|
||||
context=context,
|
||||
tags=['billing', 'subscription-renewal'],
|
||||
)
|
||||
logger.info(f'Renewal notice sent for Subscription {subscription.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send renewal notice: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=f'Subscription Renewal Reminder - {days_until_renewal} Days',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
Your subscription will be renewed in {days_until_renewal} days.
|
||||
|
||||
Subscription Details:
|
||||
- Plan: {subscription.plan.name}
|
||||
- Plan: {plan.name}
|
||||
- Renewal Date: {subscription.current_period_end.strftime('%Y-%m-%d')}
|
||||
- Amount: {subscription.plan.currency} {subscription.plan.price}
|
||||
- Amount: {context['currency']} {plan.price}
|
||||
|
||||
Your payment method will be charged automatically on the renewal date.
|
||||
|
||||
To manage your subscription or update payment details:
|
||||
{settings.FRONTEND_URL}/billing/subscription
|
||||
{context['subscription_url']}
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or user.email],
|
||||
fail_silently=False,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
logger.info(f'Renewal notice sent for Subscription {subscription.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send renewal notice: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def send_subscription_activated_email(account, subscription):
|
||||
"""
|
||||
Send email when subscription is activated.
|
||||
"""
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
plan = subscription.plan
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'plan_name': plan.name,
|
||||
'included_credits': plan.included_credits or 0,
|
||||
'period_end': subscription.current_period_end,
|
||||
'frontend_url': frontend_url,
|
||||
'dashboard_url': f'{frontend_url}/dashboard',
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=f'Subscription Activated - {plan.name}',
|
||||
template='emails/subscription_activated.html',
|
||||
context=context,
|
||||
tags=['billing', 'subscription-activated'],
|
||||
)
|
||||
logger.info(f'Subscription activated email sent for account {account.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send subscription activated email: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=f'Subscription Activated - {plan.name}',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
Your {plan.name} subscription is now active!
|
||||
|
||||
What's included:
|
||||
- {context['included_credits']} credits to start
|
||||
- All features of the {plan.name} plan
|
||||
- Active until: {context['period_end'].strftime('%Y-%m-%d')}
|
||||
|
||||
Get started now:
|
||||
{context['dashboard_url']}
|
||||
|
||||
Thank you for choosing IGNY8!
|
||||
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send_low_credits_warning(account, current_credits, threshold):
|
||||
"""
|
||||
Send email when account credits are low.
|
||||
"""
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'current_credits': current_credits,
|
||||
'threshold': threshold,
|
||||
'frontend_url': frontend_url,
|
||||
'topup_url': f'{frontend_url}/account/usage',
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Low Credits Warning - IGNY8',
|
||||
template='emails/low_credits.html',
|
||||
context=context,
|
||||
tags=['billing', 'low-credits'],
|
||||
)
|
||||
logger.info(f'Low credits warning sent for account {account.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send low credits warning: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Low Credits Warning - IGNY8',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
Your credit balance is running low.
|
||||
|
||||
Current Balance: {current_credits} credits
|
||||
|
||||
To avoid service interruption, please top up your credits:
|
||||
{context['topup_url']}
|
||||
|
||||
Thank you,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send_payment_failed_notification(account, subscription, failure_reason=None):
|
||||
"""
|
||||
Send email when a payment fails.
|
||||
"""
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A',
|
||||
'failure_reason': failure_reason or 'Payment could not be processed',
|
||||
'frontend_url': frontend_url,
|
||||
'billing_url': f'{frontend_url}/account/plans',
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Payment Failed - Action Required',
|
||||
template='emails/payment_failed.html',
|
||||
context=context,
|
||||
tags=['billing', 'payment-failed'],
|
||||
)
|
||||
logger.info(f'Payment failed notification sent for account {account.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment failed notification: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Payment Failed - Action Required',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
We were unable to process your payment for the {context['plan_name']} plan.
|
||||
|
||||
Reason: {context['failure_reason']}
|
||||
|
||||
Please update your payment method to continue your subscription:
|
||||
{context['billing_url']}
|
||||
|
||||
If you need assistance, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
|
||||
# ========== Convenience Functions ==========
|
||||
|
||||
def send_welcome_email(user, account):
|
||||
"""Send welcome email after signup"""
|
||||
service = get_email_service()
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
context = {
|
||||
'user_name': user.first_name or user.email,
|
||||
'account_name': account.name,
|
||||
'login_url': f'{frontend_url}/login',
|
||||
'frontend_url': frontend_url,
|
||||
}
|
||||
|
||||
return service.send_transactional(
|
||||
to=user.email,
|
||||
subject='Welcome to IGNY8!',
|
||||
template='emails/welcome.html',
|
||||
context=context,
|
||||
tags=['auth', 'welcome'],
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_email(user, reset_token):
|
||||
"""Send password reset email"""
|
||||
service = get_email_service()
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
context = {
|
||||
'user_name': user.first_name or user.email,
|
||||
'reset_url': f'{frontend_url}/reset-password?token={reset_token}',
|
||||
'frontend_url': frontend_url,
|
||||
}
|
||||
|
||||
return service.send_transactional(
|
||||
to=user.email,
|
||||
subject='Reset Your IGNY8 Password',
|
||||
template='emails/password_reset.html',
|
||||
context=context,
|
||||
tags=['auth', 'password-reset'],
|
||||
)
|
||||
|
||||
|
||||
def send_email_verification(user, verification_token):
|
||||
"""Send email verification link"""
|
||||
service = get_email_service()
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
context = {
|
||||
'user_name': user.first_name or user.email,
|
||||
'verification_url': f'{frontend_url}/verify-email?token={verification_token}',
|
||||
'frontend_url': frontend_url,
|
||||
}
|
||||
|
||||
return service.send_transactional(
|
||||
to=user.email,
|
||||
subject='Verify Your IGNY8 Email',
|
||||
template='emails/email_verification.html',
|
||||
context=context,
|
||||
tags=['auth', 'email-verification'],
|
||||
)
|
||||
|
||||
679
backend/igny8_core/business/billing/services/paypal_service.py
Normal file
679
backend/igny8_core/business/billing/services/paypal_service.py
Normal file
@@ -0,0 +1,679 @@
|
||||
"""
|
||||
PayPal Service - REST API v2 integration
|
||||
|
||||
Handles:
|
||||
- Order creation and capture for one-time payments
|
||||
- Subscription management
|
||||
- Webhook verification
|
||||
|
||||
Configuration stored in IntegrationProvider model (provider_id='paypal')
|
||||
|
||||
Endpoints:
|
||||
- Sandbox: https://api-m.sandbox.paypal.com
|
||||
- Production: https://api-m.paypal.com
|
||||
"""
|
||||
import requests
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from django.conf import settings
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PayPalConfigurationError(Exception):
|
||||
"""Raised when PayPal is not properly configured"""
|
||||
pass
|
||||
|
||||
|
||||
class PayPalAPIError(Exception):
|
||||
"""Raised when PayPal API returns an error"""
|
||||
def __init__(self, message: str, status_code: int = None, response: dict = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response = response
|
||||
|
||||
|
||||
class PayPalService:
|
||||
"""Service for PayPal payment operations using REST API v2"""
|
||||
|
||||
SANDBOX_URL = 'https://api-m.sandbox.paypal.com'
|
||||
PRODUCTION_URL = 'https://api-m.paypal.com'
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize PayPal service with credentials from IntegrationProvider.
|
||||
|
||||
Raises:
|
||||
PayPalConfigurationError: If PayPal provider not configured or missing credentials
|
||||
"""
|
||||
provider = IntegrationProvider.get_provider('paypal')
|
||||
if not provider:
|
||||
raise PayPalConfigurationError(
|
||||
"PayPal provider not configured. Add 'paypal' provider in admin."
|
||||
)
|
||||
|
||||
if not provider.api_key or not provider.api_secret:
|
||||
raise PayPalConfigurationError(
|
||||
"PayPal client credentials not configured. "
|
||||
"Set api_key (Client ID) and api_secret (Client Secret) in provider."
|
||||
)
|
||||
|
||||
self.client_id = provider.api_key
|
||||
self.client_secret = provider.api_secret
|
||||
self.is_sandbox = provider.is_sandbox
|
||||
self.provider = provider
|
||||
self.config = provider.config or {}
|
||||
|
||||
# Set base URL
|
||||
if provider.api_endpoint:
|
||||
self.base_url = provider.api_endpoint.rstrip('/')
|
||||
else:
|
||||
self.base_url = self.SANDBOX_URL if self.is_sandbox else self.PRODUCTION_URL
|
||||
|
||||
# Cache access token
|
||||
self._access_token = None
|
||||
self._token_expires_at = None
|
||||
|
||||
# Configuration
|
||||
self.currency = self.config.get('currency', 'USD')
|
||||
self.webhook_id = self.config.get('webhook_id', '')
|
||||
|
||||
logger.info(
|
||||
f"PayPal service initialized (sandbox={self.is_sandbox}, "
|
||||
f"base_url={self.base_url})"
|
||||
)
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> str:
|
||||
"""Get frontend URL from Django settings"""
|
||||
return getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
@property
|
||||
def return_url(self) -> str:
|
||||
"""Get return URL for PayPal redirects"""
|
||||
return self.config.get(
|
||||
'return_url',
|
||||
f'{self.frontend_url}/account/plans?paypal=success'
|
||||
)
|
||||
|
||||
@property
|
||||
def cancel_url(self) -> str:
|
||||
"""Get cancel URL for PayPal redirects"""
|
||||
return self.config.get(
|
||||
'cancel_url',
|
||||
f'{self.frontend_url}/account/plans?paypal=cancel'
|
||||
)
|
||||
|
||||
# ========== Authentication ==========
|
||||
|
||||
def _get_access_token(self) -> str:
|
||||
"""
|
||||
Get OAuth 2.0 access token from PayPal.
|
||||
|
||||
Returns:
|
||||
str: Access token
|
||||
|
||||
Raises:
|
||||
PayPalAPIError: If token request fails
|
||||
"""
|
||||
import time
|
||||
|
||||
# Return cached token if still valid
|
||||
if self._access_token and self._token_expires_at:
|
||||
if time.time() < self._token_expires_at - 60: # 60 second buffer
|
||||
return self._access_token
|
||||
|
||||
# Create Basic auth header
|
||||
auth_string = f'{self.client_id}:{self.client_secret}'
|
||||
auth_bytes = base64.b64encode(auth_string.encode()).decode()
|
||||
|
||||
response = requests.post(
|
||||
f'{self.base_url}/v1/oauth2/token',
|
||||
headers={
|
||||
'Authorization': f'Basic {auth_bytes}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
data='grant_type=client_credentials',
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"PayPal token request failed: {response.text}")
|
||||
raise PayPalAPIError(
|
||||
"Failed to obtain PayPal access token",
|
||||
status_code=response.status_code,
|
||||
response=response.json() if response.text else None
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
self._access_token = data['access_token']
|
||||
self._token_expires_at = time.time() + data.get('expires_in', 32400)
|
||||
|
||||
logger.debug("PayPal access token obtained successfully")
|
||||
return self._access_token
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_data: dict = None,
|
||||
params: dict = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""
|
||||
Make authenticated API request to PayPal.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
endpoint: API endpoint (e.g., '/v2/checkout/orders')
|
||||
json_data: JSON body data
|
||||
params: Query parameters
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Response JSON
|
||||
|
||||
Raises:
|
||||
PayPalAPIError: If request fails
|
||||
"""
|
||||
token = self._get_access_token()
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
url = f'{self.base_url}{endpoint}'
|
||||
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Handle no content response
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
response_data = response.json() if response.text else {}
|
||||
except Exception:
|
||||
response_data = {'raw': response.text}
|
||||
|
||||
# Check for errors
|
||||
if response.status_code >= 400:
|
||||
error_msg = response_data.get('message', str(response_data))
|
||||
logger.error(f"PayPal API error: {error_msg}")
|
||||
raise PayPalAPIError(
|
||||
f"PayPal API error: {error_msg}",
|
||||
status_code=response.status_code,
|
||||
response=response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
# ========== Order Operations ==========
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
account,
|
||||
amount: float,
|
||||
currency: str = None,
|
||||
description: str = '',
|
||||
return_url: str = None,
|
||||
cancel_url: str = None,
|
||||
metadata: dict = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create PayPal order for one-time payment.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
amount: Payment amount
|
||||
currency: Currency code (default from config)
|
||||
description: Payment description
|
||||
return_url: URL to redirect after approval
|
||||
cancel_url: URL to redirect on cancellation
|
||||
metadata: Additional metadata to store
|
||||
|
||||
Returns:
|
||||
dict: Order data including order_id and approval_url
|
||||
"""
|
||||
currency = currency or self.currency
|
||||
return_url = return_url or self.return_url
|
||||
cancel_url = cancel_url or self.cancel_url
|
||||
|
||||
# Build order payload
|
||||
order_data = {
|
||||
'intent': 'CAPTURE',
|
||||
'purchase_units': [{
|
||||
'amount': {
|
||||
'currency_code': currency,
|
||||
'value': f'{amount:.2f}',
|
||||
},
|
||||
'description': description or 'IGNY8 Payment',
|
||||
'custom_id': str(account.id),
|
||||
'reference_id': str(account.id),
|
||||
}],
|
||||
'application_context': {
|
||||
'return_url': return_url,
|
||||
'cancel_url': cancel_url,
|
||||
'brand_name': 'IGNY8',
|
||||
'landing_page': 'BILLING',
|
||||
'user_action': 'PAY_NOW',
|
||||
'shipping_preference': 'NO_SHIPPING',
|
||||
}
|
||||
}
|
||||
|
||||
# Create order
|
||||
response = self._make_request('POST', '/v2/checkout/orders', json_data=order_data)
|
||||
|
||||
# Extract approval URL
|
||||
approval_url = None
|
||||
for link in response.get('links', []):
|
||||
if link.get('rel') == 'approve':
|
||||
approval_url = link.get('href')
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"Created PayPal order {response.get('id')} for account {account.id}, "
|
||||
f"amount {currency} {amount}"
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'approval_url': approval_url,
|
||||
'links': response.get('links', []),
|
||||
}
|
||||
|
||||
def create_credit_order(
|
||||
self,
|
||||
account,
|
||||
credit_package,
|
||||
return_url: str = None,
|
||||
cancel_url: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create PayPal order for credit package purchase.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
credit_package: CreditPackage model instance
|
||||
return_url: URL to redirect after approval
|
||||
cancel_url: URL to redirect on cancellation
|
||||
|
||||
Returns:
|
||||
dict: Order data including order_id and approval_url
|
||||
"""
|
||||
return_url = return_url or f'{self.frontend_url}/account/usage?paypal=success'
|
||||
cancel_url = cancel_url or f'{self.frontend_url}/account/usage?paypal=cancel'
|
||||
|
||||
# Add credit package info to custom_id for webhook processing
|
||||
order = self.create_order(
|
||||
account=account,
|
||||
amount=float(credit_package.price),
|
||||
description=f'{credit_package.name} - {credit_package.credits} credits',
|
||||
return_url=f'{return_url}&package_id={credit_package.id}',
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
|
||||
# Store package info in order
|
||||
order['credit_package_id'] = str(credit_package.id)
|
||||
order['credit_amount'] = credit_package.credits
|
||||
|
||||
return order
|
||||
|
||||
def capture_order(self, order_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Capture payment for approved order.
|
||||
|
||||
Call this after customer approves the order at PayPal.
|
||||
|
||||
Args:
|
||||
order_id: PayPal order ID
|
||||
|
||||
Returns:
|
||||
dict: Capture result with payment details
|
||||
"""
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
f'/v2/checkout/orders/{order_id}/capture'
|
||||
)
|
||||
|
||||
# Extract capture details
|
||||
capture_id = None
|
||||
amount = None
|
||||
currency = None
|
||||
|
||||
if response.get('purchase_units'):
|
||||
captures = response['purchase_units'][0].get('payments', {}).get('captures', [])
|
||||
if captures:
|
||||
capture = captures[0]
|
||||
capture_id = capture.get('id')
|
||||
amount = capture.get('amount', {}).get('value')
|
||||
currency = capture.get('amount', {}).get('currency_code')
|
||||
|
||||
logger.info(
|
||||
f"Captured PayPal order {order_id}, capture_id={capture_id}, "
|
||||
f"amount={currency} {amount}"
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'capture_id': capture_id,
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'payer': response.get('payer', {}),
|
||||
'custom_id': response.get('purchase_units', [{}])[0].get('custom_id'),
|
||||
}
|
||||
|
||||
def get_order(self, order_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get order details.
|
||||
|
||||
Args:
|
||||
order_id: PayPal order ID
|
||||
|
||||
Returns:
|
||||
dict: Order details
|
||||
"""
|
||||
response = self._make_request('GET', f'/v2/checkout/orders/{order_id}')
|
||||
|
||||
return {
|
||||
'order_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'intent': response.get('intent'),
|
||||
'payer': response.get('payer', {}),
|
||||
'purchase_units': response.get('purchase_units', []),
|
||||
'create_time': response.get('create_time'),
|
||||
'update_time': response.get('update_time'),
|
||||
}
|
||||
|
||||
# ========== Subscription Operations ==========
|
||||
|
||||
def create_subscription(
|
||||
self,
|
||||
account,
|
||||
plan_id: str,
|
||||
return_url: str = None,
|
||||
cancel_url: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create PayPal subscription.
|
||||
|
||||
Requires plan to be created in PayPal dashboard first.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
plan_id: PayPal Plan ID (created in PayPal dashboard)
|
||||
return_url: URL to redirect after approval
|
||||
cancel_url: URL to redirect on cancellation
|
||||
|
||||
Returns:
|
||||
dict: Subscription data including approval_url
|
||||
"""
|
||||
return_url = return_url or self.return_url
|
||||
cancel_url = cancel_url or self.cancel_url
|
||||
|
||||
subscription_data = {
|
||||
'plan_id': plan_id,
|
||||
'custom_id': str(account.id),
|
||||
'application_context': {
|
||||
'return_url': return_url,
|
||||
'cancel_url': cancel_url,
|
||||
'brand_name': 'IGNY8',
|
||||
'locale': 'en-US',
|
||||
'shipping_preference': 'NO_SHIPPING',
|
||||
'user_action': 'SUBSCRIBE_NOW',
|
||||
'payment_method': {
|
||||
'payer_selected': 'PAYPAL',
|
||||
'payee_preferred': 'IMMEDIATE_PAYMENT_REQUIRED',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
'/v1/billing/subscriptions',
|
||||
json_data=subscription_data
|
||||
)
|
||||
|
||||
# Extract approval URL
|
||||
approval_url = None
|
||||
for link in response.get('links', []):
|
||||
if link.get('rel') == 'approve':
|
||||
approval_url = link.get('href')
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"Created PayPal subscription {response.get('id')} for account {account.id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'subscription_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'approval_url': approval_url,
|
||||
'links': response.get('links', []),
|
||||
}
|
||||
|
||||
def get_subscription(self, subscription_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get subscription details.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
|
||||
Returns:
|
||||
dict: Subscription details
|
||||
"""
|
||||
response = self._make_request(
|
||||
'GET',
|
||||
f'/v1/billing/subscriptions/{subscription_id}'
|
||||
)
|
||||
|
||||
return {
|
||||
'subscription_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'plan_id': response.get('plan_id'),
|
||||
'start_time': response.get('start_time'),
|
||||
'billing_info': response.get('billing_info', {}),
|
||||
'custom_id': response.get('custom_id'),
|
||||
}
|
||||
|
||||
def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
reason: str = 'Customer requested cancellation'
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Cancel PayPal subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
reason: Reason for cancellation
|
||||
|
||||
Returns:
|
||||
dict: Cancellation result
|
||||
"""
|
||||
self._make_request(
|
||||
'POST',
|
||||
f'/v1/billing/subscriptions/{subscription_id}/cancel',
|
||||
json_data={'reason': reason}
|
||||
)
|
||||
|
||||
logger.info(f"Cancelled PayPal subscription {subscription_id}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription_id,
|
||||
'status': 'CANCELLED',
|
||||
}
|
||||
|
||||
def suspend_subscription(self, subscription_id: str, reason: str = '') -> Dict[str, Any]:
|
||||
"""
|
||||
Suspend PayPal subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
reason: Reason for suspension
|
||||
|
||||
Returns:
|
||||
dict: Suspension result
|
||||
"""
|
||||
self._make_request(
|
||||
'POST',
|
||||
f'/v1/billing/subscriptions/{subscription_id}/suspend',
|
||||
json_data={'reason': reason}
|
||||
)
|
||||
|
||||
logger.info(f"Suspended PayPal subscription {subscription_id}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription_id,
|
||||
'status': 'SUSPENDED',
|
||||
}
|
||||
|
||||
def activate_subscription(self, subscription_id: str, reason: str = '') -> Dict[str, Any]:
|
||||
"""
|
||||
Activate/reactivate PayPal subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
reason: Reason for activation
|
||||
|
||||
Returns:
|
||||
dict: Activation result
|
||||
"""
|
||||
self._make_request(
|
||||
'POST',
|
||||
f'/v1/billing/subscriptions/{subscription_id}/activate',
|
||||
json_data={'reason': reason}
|
||||
)
|
||||
|
||||
logger.info(f"Activated PayPal subscription {subscription_id}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription_id,
|
||||
'status': 'ACTIVE',
|
||||
}
|
||||
|
||||
# ========== Webhook Verification ==========
|
||||
|
||||
def verify_webhook_signature(
|
||||
self,
|
||||
headers: dict,
|
||||
body: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify webhook signature from PayPal.
|
||||
|
||||
Args:
|
||||
headers: Request headers (dict-like)
|
||||
body: Request body (parsed JSON dict)
|
||||
|
||||
Returns:
|
||||
bool: True if signature is valid
|
||||
"""
|
||||
if not self.webhook_id:
|
||||
logger.warning("PayPal webhook_id not configured, skipping verification")
|
||||
return True # Optionally fail open or closed based on security policy
|
||||
|
||||
verification_data = {
|
||||
'auth_algo': headers.get('PAYPAL-AUTH-ALGO'),
|
||||
'cert_url': headers.get('PAYPAL-CERT-URL'),
|
||||
'transmission_id': headers.get('PAYPAL-TRANSMISSION-ID'),
|
||||
'transmission_sig': headers.get('PAYPAL-TRANSMISSION-SIG'),
|
||||
'transmission_time': headers.get('PAYPAL-TRANSMISSION-TIME'),
|
||||
'webhook_id': self.webhook_id,
|
||||
'webhook_event': body,
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
'/v1/notifications/verify-webhook-signature',
|
||||
json_data=verification_data
|
||||
)
|
||||
|
||||
is_valid = response.get('verification_status') == 'SUCCESS'
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"PayPal webhook verification failed: {response.get('verification_status')}"
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
except PayPalAPIError as e:
|
||||
logger.error(f"PayPal webhook verification error: {e}")
|
||||
return False
|
||||
|
||||
# ========== Refunds ==========
|
||||
|
||||
def refund_capture(
|
||||
self,
|
||||
capture_id: str,
|
||||
amount: float = None,
|
||||
currency: str = None,
|
||||
note: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a captured payment.
|
||||
|
||||
Args:
|
||||
capture_id: PayPal capture ID
|
||||
amount: Amount to refund (None for full refund)
|
||||
currency: Currency code
|
||||
note: Note to payer
|
||||
|
||||
Returns:
|
||||
dict: Refund details
|
||||
"""
|
||||
refund_data = {}
|
||||
|
||||
if amount:
|
||||
refund_data['amount'] = {
|
||||
'value': f'{amount:.2f}',
|
||||
'currency_code': currency or self.currency,
|
||||
}
|
||||
|
||||
if note:
|
||||
refund_data['note_to_payer'] = note
|
||||
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
f'/v2/payments/captures/{capture_id}/refund',
|
||||
json_data=refund_data if refund_data else None
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Refunded PayPal capture {capture_id}, refund_id={response.get('id')}"
|
||||
)
|
||||
|
||||
return {
|
||||
'refund_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'amount': response.get('amount', {}).get('value'),
|
||||
'currency': response.get('amount', {}).get('currency_code'),
|
||||
}
|
||||
|
||||
|
||||
# Convenience function
|
||||
def get_paypal_service() -> PayPalService:
|
||||
"""
|
||||
Get PayPalService instance.
|
||||
|
||||
Returns:
|
||||
PayPalService: Initialized service
|
||||
|
||||
Raises:
|
||||
PayPalConfigurationError: If PayPal not configured
|
||||
"""
|
||||
return PayPalService()
|
||||
627
backend/igny8_core/business/billing/services/stripe_service.py
Normal file
627
backend/igny8_core/business/billing/services/stripe_service.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
Stripe Service - Wrapper for Stripe API operations
|
||||
|
||||
Handles:
|
||||
- Checkout sessions for subscriptions and credit packages
|
||||
- Billing portal sessions for subscription management
|
||||
- Webhook event construction and verification
|
||||
- Customer management
|
||||
|
||||
Configuration stored in IntegrationProvider model (provider_id='stripe')
|
||||
"""
|
||||
import stripe
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StripeConfigurationError(Exception):
|
||||
"""Raised when Stripe is not properly configured"""
|
||||
pass
|
||||
|
||||
|
||||
class StripeService:
|
||||
"""Service for Stripe payment operations"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize Stripe service with credentials from IntegrationProvider.
|
||||
|
||||
Raises:
|
||||
StripeConfigurationError: If Stripe provider not configured or missing credentials
|
||||
"""
|
||||
provider = IntegrationProvider.get_provider('stripe')
|
||||
if not provider:
|
||||
raise StripeConfigurationError(
|
||||
"Stripe provider not configured. Add 'stripe' provider in admin."
|
||||
)
|
||||
|
||||
if not provider.api_secret:
|
||||
raise StripeConfigurationError(
|
||||
"Stripe secret key not configured. Set api_secret in provider."
|
||||
)
|
||||
|
||||
self.is_sandbox = provider.is_sandbox
|
||||
self.provider = provider
|
||||
|
||||
# Set Stripe API key
|
||||
stripe.api_key = provider.api_secret
|
||||
|
||||
# Store keys for reference
|
||||
self.publishable_key = provider.api_key
|
||||
self.webhook_secret = provider.webhook_secret
|
||||
self.config = provider.config or {}
|
||||
|
||||
# Default currency from config
|
||||
self.currency = self.config.get('currency', 'usd')
|
||||
|
||||
logger.info(
|
||||
f"Stripe service initialized (sandbox={self.is_sandbox}, "
|
||||
f"currency={self.currency})"
|
||||
)
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> str:
|
||||
"""Get frontend URL from Django settings"""
|
||||
return getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
def get_publishable_key(self) -> str:
|
||||
"""Return publishable key for frontend use"""
|
||||
return self.publishable_key
|
||||
|
||||
# ========== Customer Management ==========
|
||||
|
||||
def _get_or_create_customer(self, account) -> str:
|
||||
"""
|
||||
Get existing Stripe customer or create new one.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
|
||||
Returns:
|
||||
str: Stripe customer ID
|
||||
"""
|
||||
# Return existing customer if available
|
||||
if account.stripe_customer_id:
|
||||
try:
|
||||
# Verify customer still exists in Stripe
|
||||
stripe.Customer.retrieve(account.stripe_customer_id)
|
||||
return account.stripe_customer_id
|
||||
except stripe.error.InvalidRequestError:
|
||||
# Customer was deleted, create new one
|
||||
logger.warning(
|
||||
f"Stripe customer {account.stripe_customer_id} not found, creating new"
|
||||
)
|
||||
|
||||
# Create new customer
|
||||
customer = stripe.Customer.create(
|
||||
email=account.billing_email or account.owner.email,
|
||||
name=account.name,
|
||||
metadata={
|
||||
'account_id': str(account.id),
|
||||
'environment': 'sandbox' if self.is_sandbox else 'production'
|
||||
},
|
||||
)
|
||||
|
||||
# Save customer ID to account
|
||||
account.stripe_customer_id = customer.id
|
||||
account.save(update_fields=['stripe_customer_id', 'updated_at'])
|
||||
|
||||
logger.info(f"Created Stripe customer {customer.id} for account {account.id}")
|
||||
|
||||
return customer.id
|
||||
|
||||
def get_customer(self, account) -> Optional[Dict]:
|
||||
"""
|
||||
Get Stripe customer details.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
|
||||
Returns:
|
||||
dict: Customer data or None if not found
|
||||
"""
|
||||
if not account.stripe_customer_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(account.stripe_customer_id)
|
||||
return {
|
||||
'id': customer.id,
|
||||
'email': customer.email,
|
||||
'name': customer.name,
|
||||
'created': customer.created,
|
||||
'default_source': customer.default_source,
|
||||
}
|
||||
except stripe.error.InvalidRequestError:
|
||||
return None
|
||||
|
||||
# ========== Checkout Sessions ==========
|
||||
|
||||
def create_checkout_session(
|
||||
self,
|
||||
account,
|
||||
plan,
|
||||
success_url: Optional[str] = None,
|
||||
cancel_url: Optional[str] = None,
|
||||
allow_promotion_codes: bool = True,
|
||||
trial_period_days: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create Stripe Checkout session for new subscription.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
plan: Plan model instance with stripe_price_id
|
||||
success_url: URL to redirect after successful payment
|
||||
cancel_url: URL to redirect if payment is canceled
|
||||
allow_promotion_codes: Allow discount codes in checkout
|
||||
trial_period_days: Optional trial period (overrides plan default)
|
||||
|
||||
Returns:
|
||||
dict: Session data with checkout_url and session_id
|
||||
|
||||
Raises:
|
||||
ValueError: If plan has no stripe_price_id
|
||||
"""
|
||||
if not plan.stripe_price_id:
|
||||
raise ValueError(
|
||||
f"Plan '{plan.name}' (id={plan.id}) has no stripe_price_id configured"
|
||||
)
|
||||
|
||||
# Get or create customer
|
||||
customer_id = self._get_or_create_customer(account)
|
||||
|
||||
# Build URLs
|
||||
if not success_url:
|
||||
success_url = f'{self.frontend_url}/account/plans?success=true&session_id={{CHECKOUT_SESSION_ID}}'
|
||||
if not cancel_url:
|
||||
cancel_url = f'{self.frontend_url}/account/plans?canceled=true'
|
||||
|
||||
# Build subscription data
|
||||
subscription_data = {
|
||||
'metadata': {
|
||||
'account_id': str(account.id),
|
||||
'plan_id': str(plan.id),
|
||||
}
|
||||
}
|
||||
|
||||
if trial_period_days:
|
||||
subscription_data['trial_period_days'] = trial_period_days
|
||||
|
||||
# Create checkout session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=self.config.get('payment_methods', ['card']),
|
||||
mode='subscription',
|
||||
line_items=[{
|
||||
'price': plan.stripe_price_id,
|
||||
'quantity': 1,
|
||||
}],
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
allow_promotion_codes=allow_promotion_codes,
|
||||
metadata={
|
||||
'account_id': str(account.id),
|
||||
'plan_id': str(plan.id),
|
||||
'type': 'subscription',
|
||||
},
|
||||
subscription_data=subscription_data,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe checkout session {session.id} for account {account.id}, "
|
||||
f"plan {plan.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
'checkout_url': session.url,
|
||||
'session_id': session.id,
|
||||
}
|
||||
|
||||
def create_credit_checkout_session(
|
||||
self,
|
||||
account,
|
||||
credit_package,
|
||||
success_url: Optional[str] = None,
|
||||
cancel_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create Stripe Checkout session for one-time credit purchase.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
credit_package: CreditPackage model instance
|
||||
success_url: URL to redirect after successful payment
|
||||
cancel_url: URL to redirect if payment is canceled
|
||||
|
||||
Returns:
|
||||
dict: Session data with checkout_url and session_id
|
||||
"""
|
||||
# Get or create customer
|
||||
customer_id = self._get_or_create_customer(account)
|
||||
|
||||
# Build URLs
|
||||
if not success_url:
|
||||
success_url = f'{self.frontend_url}/account/usage?purchase=success&session_id={{CHECKOUT_SESSION_ID}}'
|
||||
if not cancel_url:
|
||||
cancel_url = f'{self.frontend_url}/account/usage?purchase=canceled'
|
||||
|
||||
# Use existing Stripe price if available, otherwise create price_data
|
||||
if credit_package.stripe_price_id:
|
||||
line_items = [{
|
||||
'price': credit_package.stripe_price_id,
|
||||
'quantity': 1,
|
||||
}]
|
||||
else:
|
||||
# Create price_data for dynamic pricing
|
||||
line_items = [{
|
||||
'price_data': {
|
||||
'currency': self.currency,
|
||||
'product_data': {
|
||||
'name': credit_package.name,
|
||||
'description': f'{credit_package.credits} credits',
|
||||
},
|
||||
'unit_amount': int(credit_package.price * 100), # Convert to cents
|
||||
},
|
||||
'quantity': 1,
|
||||
}]
|
||||
|
||||
# Create checkout session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=self.config.get('payment_methods', ['card']),
|
||||
mode='payment',
|
||||
line_items=line_items,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
metadata={
|
||||
'account_id': str(account.id),
|
||||
'credit_package_id': str(credit_package.id),
|
||||
'credit_amount': str(credit_package.credits),
|
||||
'type': 'credit_purchase',
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe credit checkout session {session.id} for account {account.id}, "
|
||||
f"package {credit_package.name} ({credit_package.credits} credits)"
|
||||
)
|
||||
|
||||
return {
|
||||
'checkout_url': session.url,
|
||||
'session_id': session.id,
|
||||
}
|
||||
|
||||
def get_checkout_session(self, session_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Retrieve checkout session details.
|
||||
|
||||
Args:
|
||||
session_id: Stripe checkout session ID
|
||||
|
||||
Returns:
|
||||
dict: Session data or None if not found
|
||||
"""
|
||||
try:
|
||||
session = stripe.checkout.Session.retrieve(session_id)
|
||||
return {
|
||||
'id': session.id,
|
||||
'status': session.status,
|
||||
'payment_status': session.payment_status,
|
||||
'customer': session.customer,
|
||||
'subscription': session.subscription,
|
||||
'metadata': session.metadata,
|
||||
'amount_total': session.amount_total,
|
||||
'currency': session.currency,
|
||||
}
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
logger.error(f"Failed to retrieve checkout session {session_id}: {e}")
|
||||
return None
|
||||
|
||||
# ========== Billing Portal ==========
|
||||
|
||||
def create_billing_portal_session(
|
||||
self,
|
||||
account,
|
||||
return_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create Stripe Billing Portal session for subscription management.
|
||||
|
||||
Allows customers to:
|
||||
- Update payment method
|
||||
- View billing history
|
||||
- Cancel subscription
|
||||
- Update billing info
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
return_url: URL to return to after portal session
|
||||
|
||||
Returns:
|
||||
dict: Portal session data with portal_url
|
||||
|
||||
Raises:
|
||||
ValueError: If account has no Stripe customer
|
||||
"""
|
||||
if not self.config.get('billing_portal_enabled', True):
|
||||
raise ValueError("Billing portal is disabled in configuration")
|
||||
|
||||
# Get or create customer
|
||||
customer_id = self._get_or_create_customer(account)
|
||||
|
||||
if not return_url:
|
||||
return_url = f'{self.frontend_url}/account/plans'
|
||||
|
||||
# Create billing portal session
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe billing portal session for account {account.id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'portal_url': session.url,
|
||||
}
|
||||
|
||||
# ========== Subscription Management ==========
|
||||
|
||||
def get_subscription(self, subscription_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get subscription details from Stripe.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
|
||||
Returns:
|
||||
dict: Subscription data or None if not found
|
||||
"""
|
||||
try:
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
return {
|
||||
'id': sub.id,
|
||||
'status': sub.status,
|
||||
'current_period_start': sub.current_period_start,
|
||||
'current_period_end': sub.current_period_end,
|
||||
'cancel_at_period_end': sub.cancel_at_period_end,
|
||||
'canceled_at': sub.canceled_at,
|
||||
'ended_at': sub.ended_at,
|
||||
'customer': sub.customer,
|
||||
'items': [{
|
||||
'id': item.id,
|
||||
'price_id': item.price.id,
|
||||
'quantity': item.quantity,
|
||||
} for item in sub['items'].data],
|
||||
'metadata': sub.metadata,
|
||||
}
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
logger.error(f"Failed to retrieve subscription {subscription_id}: {e}")
|
||||
return None
|
||||
|
||||
def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
at_period_end: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Cancel a Stripe subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
at_period_end: If True, cancel at end of billing period
|
||||
|
||||
Returns:
|
||||
dict: Updated subscription data
|
||||
"""
|
||||
if at_period_end:
|
||||
sub = stripe.Subscription.modify(
|
||||
subscription_id,
|
||||
cancel_at_period_end=True
|
||||
)
|
||||
logger.info(f"Subscription {subscription_id} marked for cancellation at period end")
|
||||
else:
|
||||
sub = stripe.Subscription.delete(subscription_id)
|
||||
logger.info(f"Subscription {subscription_id} canceled immediately")
|
||||
|
||||
return {
|
||||
'id': sub.id,
|
||||
'status': sub.status,
|
||||
'cancel_at_period_end': sub.cancel_at_period_end,
|
||||
}
|
||||
|
||||
def update_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
new_price_id: str,
|
||||
proration_behavior: str = 'create_prorations'
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update subscription to a new plan/price.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
new_price_id: New Stripe price ID
|
||||
proration_behavior: How to handle proration
|
||||
- 'create_prorations': Prorate the change
|
||||
- 'none': No proration
|
||||
- 'always_invoice': Invoice immediately
|
||||
|
||||
Returns:
|
||||
dict: Updated subscription data
|
||||
"""
|
||||
# Get current subscription
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
|
||||
# Update the subscription item
|
||||
updated = stripe.Subscription.modify(
|
||||
subscription_id,
|
||||
items=[{
|
||||
'id': sub['items'].data[0].id,
|
||||
'price': new_price_id,
|
||||
}],
|
||||
proration_behavior=proration_behavior,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Updated subscription {subscription_id} to price {new_price_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'id': updated.id,
|
||||
'status': updated.status,
|
||||
'current_period_end': updated.current_period_end,
|
||||
}
|
||||
|
||||
# ========== Webhook Handling ==========
|
||||
|
||||
def construct_webhook_event(
|
||||
self,
|
||||
payload: bytes,
|
||||
sig_header: str
|
||||
) -> stripe.Event:
|
||||
"""
|
||||
Verify and construct webhook event from Stripe.
|
||||
|
||||
Args:
|
||||
payload: Raw request body
|
||||
sig_header: Stripe-Signature header value
|
||||
|
||||
Returns:
|
||||
stripe.Event: Verified event object
|
||||
|
||||
Raises:
|
||||
stripe.error.SignatureVerificationError: If signature is invalid
|
||||
"""
|
||||
if not self.webhook_secret:
|
||||
raise StripeConfigurationError(
|
||||
"Webhook secret not configured. Set webhook_secret in provider."
|
||||
)
|
||||
|
||||
return stripe.Webhook.construct_event(
|
||||
payload, sig_header, self.webhook_secret
|
||||
)
|
||||
|
||||
# ========== Invoice Operations ==========
|
||||
|
||||
def get_invoice(self, invoice_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get invoice details from Stripe.
|
||||
|
||||
Args:
|
||||
invoice_id: Stripe invoice ID
|
||||
|
||||
Returns:
|
||||
dict: Invoice data or None if not found
|
||||
"""
|
||||
try:
|
||||
invoice = stripe.Invoice.retrieve(invoice_id)
|
||||
return {
|
||||
'id': invoice.id,
|
||||
'status': invoice.status,
|
||||
'amount_due': invoice.amount_due,
|
||||
'amount_paid': invoice.amount_paid,
|
||||
'currency': invoice.currency,
|
||||
'customer': invoice.customer,
|
||||
'subscription': invoice.subscription,
|
||||
'invoice_pdf': invoice.invoice_pdf,
|
||||
'hosted_invoice_url': invoice.hosted_invoice_url,
|
||||
}
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
logger.error(f"Failed to retrieve invoice {invoice_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_upcoming_invoice(self, customer_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get upcoming invoice for a customer.
|
||||
|
||||
Args:
|
||||
customer_id: Stripe customer ID
|
||||
|
||||
Returns:
|
||||
dict: Upcoming invoice preview or None
|
||||
"""
|
||||
try:
|
||||
invoice = stripe.Invoice.upcoming(customer=customer_id)
|
||||
return {
|
||||
'amount_due': invoice.amount_due,
|
||||
'currency': invoice.currency,
|
||||
'next_payment_attempt': invoice.next_payment_attempt,
|
||||
'lines': [{
|
||||
'description': line.description,
|
||||
'amount': line.amount,
|
||||
} for line in invoice.lines.data],
|
||||
}
|
||||
except stripe.error.InvalidRequestError:
|
||||
return None
|
||||
|
||||
# ========== Refunds ==========
|
||||
|
||||
def create_refund(
|
||||
self,
|
||||
payment_intent_id: Optional[str] = None,
|
||||
charge_id: Optional[str] = None,
|
||||
amount: Optional[int] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a refund for a payment.
|
||||
|
||||
Args:
|
||||
payment_intent_id: Stripe PaymentIntent ID
|
||||
charge_id: Stripe Charge ID (alternative to payment_intent_id)
|
||||
amount: Amount to refund in cents (None for full refund)
|
||||
reason: Reason for refund ('duplicate', 'fraudulent', 'requested_by_customer')
|
||||
|
||||
Returns:
|
||||
dict: Refund data
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if payment_intent_id:
|
||||
params['payment_intent'] = payment_intent_id
|
||||
elif charge_id:
|
||||
params['charge'] = charge_id
|
||||
else:
|
||||
raise ValueError("Either payment_intent_id or charge_id required")
|
||||
|
||||
if amount:
|
||||
params['amount'] = amount
|
||||
|
||||
if reason:
|
||||
params['reason'] = reason
|
||||
|
||||
refund = stripe.Refund.create(**params)
|
||||
|
||||
logger.info(
|
||||
f"Created refund {refund.id} for "
|
||||
f"{'payment_intent ' + payment_intent_id if payment_intent_id else 'charge ' + charge_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'id': refund.id,
|
||||
'amount': refund.amount,
|
||||
'status': refund.status,
|
||||
'reason': refund.reason,
|
||||
}
|
||||
|
||||
|
||||
# Convenience function
|
||||
def get_stripe_service() -> StripeService:
|
||||
"""
|
||||
Get StripeService instance.
|
||||
|
||||
Returns:
|
||||
StripeService: Initialized service
|
||||
|
||||
Raises:
|
||||
StripeConfigurationError: If Stripe not configured
|
||||
"""
|
||||
return StripeService()
|
||||
@@ -15,6 +15,22 @@ from igny8_core.modules.billing.views import (
|
||||
CreditTransactionViewSet,
|
||||
AIModelConfigViewSet,
|
||||
)
|
||||
# Payment gateway views
|
||||
from .views.stripe_views import (
|
||||
StripeConfigView,
|
||||
StripeCheckoutView,
|
||||
StripeCreditCheckoutView,
|
||||
StripeBillingPortalView,
|
||||
stripe_webhook,
|
||||
)
|
||||
from .views.paypal_views import (
|
||||
PayPalConfigView,
|
||||
PayPalCreateOrderView,
|
||||
PayPalCreateSubscriptionOrderView,
|
||||
PayPalCaptureOrderView,
|
||||
PayPalCreateSubscriptionView,
|
||||
paypal_webhook,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'admin', BillingViewSet, basename='billing-admin')
|
||||
@@ -35,4 +51,19 @@ urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
# User-facing usage summary endpoint for plan limits
|
||||
path('usage-summary/', get_usage_summary, name='usage-summary'),
|
||||
|
||||
# Stripe endpoints
|
||||
path('stripe/config/', StripeConfigView.as_view(), name='stripe-config'),
|
||||
path('stripe/checkout/', StripeCheckoutView.as_view(), name='stripe-checkout'),
|
||||
path('stripe/credit-checkout/', StripeCreditCheckoutView.as_view(), name='stripe-credit-checkout'),
|
||||
path('stripe/billing-portal/', StripeBillingPortalView.as_view(), name='stripe-billing-portal'),
|
||||
path('webhooks/stripe/', stripe_webhook, name='stripe-webhook'),
|
||||
|
||||
# PayPal endpoints
|
||||
path('paypal/config/', PayPalConfigView.as_view(), name='paypal-config'),
|
||||
path('paypal/create-order/', PayPalCreateOrderView.as_view(), name='paypal-create-order'),
|
||||
path('paypal/create-subscription-order/', PayPalCreateSubscriptionOrderView.as_view(), name='paypal-create-subscription-order'),
|
||||
path('paypal/capture-order/', PayPalCaptureOrderView.as_view(), name='paypal-capture-order'),
|
||||
path('paypal/create-subscription/', PayPalCreateSubscriptionView.as_view(), name='paypal-create-subscription'),
|
||||
path('webhooks/paypal/', paypal_webhook, name='paypal-webhook'),
|
||||
]
|
||||
|
||||
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}")
|
||||
112
backend/igny8_core/templates/emails/base.html
Normal file
112
backend/igny8_core/templates/emails/base.html
Normal file
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IGNY8{% endblock %}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 3px solid #6366f1;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.content {
|
||||
background-color: #ffffff;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #888888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: #6366f1;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4f46e5;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #f8fafc;
|
||||
border-left: 4px solid #6366f1;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.success-box {
|
||||
background-color: #d1fae5;
|
||||
border-left: 4px solid #10b981;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #1f2937;
|
||||
}
|
||||
a {
|
||||
color: #6366f1;
|
||||
}
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.details-table td {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.details-table td:first-child {
|
||||
color: #6b7280;
|
||||
width: 40%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="{{ frontend_url }}" class="logo">IGNY8</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{ current_year|default:"2026" }} IGNY8. All rights reserved.</p>
|
||||
<p>
|
||||
<a href="{{ frontend_url }}/privacy">Privacy Policy</a> |
|
||||
<a href="{{ frontend_url }}/terms">Terms of Service</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
33
backend/igny8_core/templates/emails/email_verification.html
Normal file
33
backend/igny8_core/templates/emails/email_verification.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Verify Your Email{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Verify Your Email Address</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<p>Thanks for signing up! Please verify your email address by clicking the button below:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ verification_url }}" class="button">Verify Email</a>
|
||||
</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Why verify?</strong>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>Secure your account</li>
|
||||
<li>Receive important notifications</li>
|
||||
<li>Access all features</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #6b7280; font-size: 14px;">{{ verification_url }}</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
39
backend/igny8_core/templates/emails/low_credits.html
Normal file
39
backend/igny8_core/templates/emails/low_credits.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Low Credits Warning{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Low Credits Warning</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Heads up!</strong> Your credit balance is running low.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Credit Status</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Current Balance</td>
|
||||
<td><strong style="color: #dc2626;">{{ current_credits }} credits</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Warning Threshold</td>
|
||||
<td>{{ threshold }} credits</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>To avoid service interruption, we recommend topping up your credits soon.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ topup_url }}" class="button">Buy More Credits</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Tip:</strong> Consider enabling auto-top-up in your account settings to never run out of credits again!</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
31
backend/igny8_core/templates/emails/password_reset.html
Normal file
31
backend/igny8_core/templates/emails/password_reset.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Reset Your Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Reset Your Password</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ reset_url }}" class="button">Reset Password</a>
|
||||
</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Important:</strong>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>This link expires in 24 hours</li>
|
||||
<li>If you didn't request this, you can safely ignore this email</li>
|
||||
<li>Your password won't change until you create a new one</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #6b7280; font-size: 14px;">{{ reset_url }}</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
47
backend/igny8_core/templates/emails/payment_approved.html
Normal file
47
backend/igny8_core/templates/emails/payment_approved.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Approved{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Approved!</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="success-box">
|
||||
<strong>Great news!</strong> Your payment has been approved and your account is now active.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Payment Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td><strong>{{ currency }} {{ amount }}</strong></td>
|
||||
</tr>
|
||||
{% if plan_name and plan_name != 'N/A' %}
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td>{{ plan_name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Approved</td>
|
||||
<td>{{ approved_at|date:"F j, Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You can now access all features of your plan. Log in to get started!</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing IGNY8!<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Confirmation Received{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Confirmation Received</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<p>We have received your payment confirmation and it is now being reviewed by our team.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Payment Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td><strong>{{ currency }} {{ amount }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Payment Method</td>
|
||||
<td>{{ payment_method }}</td>
|
||||
</tr>
|
||||
{% if manual_reference %}
|
||||
<tr>
|
||||
<td>Reference</td>
|
||||
<td>{{ manual_reference }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Submitted</td>
|
||||
<td>{{ created_at|date:"F j, Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>We typically process payments within 1-2 business days. You'll receive another email once your payment has been approved and your account is activated.</p>
|
||||
|
||||
<p>
|
||||
Thank you for your patience,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
39
backend/igny8_core/templates/emails/payment_failed.html
Normal file
39
backend/igny8_core/templates/emails/payment_failed.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Failed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Failed - Action Required</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="warning-box">
|
||||
We were unable to process your payment for the <strong>{{ plan_name }}</strong> plan.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td>{{ plan_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reason</td>
|
||||
<td style="color: #dc2626;">{{ failure_reason }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>Please update your payment method to continue your subscription and avoid service interruption.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ billing_url }}" class="button">Update Payment Method</a>
|
||||
</p>
|
||||
|
||||
<p>If you need assistance, please contact our support team by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
49
backend/igny8_core/templates/emails/payment_rejected.html
Normal file
49
backend/igny8_core/templates/emails/payment_rejected.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Declined{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Declined</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="warning-box">
|
||||
Unfortunately, we were unable to approve your payment for Invoice <strong>#{{ invoice_number }}</strong>.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td>{{ currency }} {{ amount }}</td>
|
||||
</tr>
|
||||
{% if manual_reference %}
|
||||
<tr>
|
||||
<td>Reference</td>
|
||||
<td>{{ manual_reference }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Reason</td>
|
||||
<td style="color: #dc2626;">{{ reason }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You can retry your payment by logging into your account:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ billing_url }}" class="button">Retry Payment</a>
|
||||
</p>
|
||||
|
||||
<p>If you believe this is an error or have questions, please contact our support team by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
49
backend/igny8_core/templates/emails/refund_notification.html
Normal file
49
backend/igny8_core/templates/emails/refund_notification.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Refund Processed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Refund Processed</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<div class="success-box">
|
||||
Your refund has been processed successfully.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Refund Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original Amount</td>
|
||||
<td>{{ currency }} {{ original_amount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Refund Amount</td>
|
||||
<td><strong>{{ currency }} {{ refund_amount }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reason</td>
|
||||
<td>{{ reason }}</td>
|
||||
</tr>
|
||||
{% if refunded_at %}
|
||||
<tr>
|
||||
<td>Processed</td>
|
||||
<td>{{ refunded_at|date:"F j, Y H:i" }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.</p>
|
||||
|
||||
<p>If you have any questions about this refund, please contact our support team by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Subscription Activated{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Your Subscription is Active!</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="success-box">
|
||||
Your <strong>{{ plan_name }}</strong> subscription is now active!
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">What's Included</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td><strong>{{ plan_name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Credits</td>
|
||||
<td><strong>{{ included_credits }}</strong> credits added to your account</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Until</td>
|
||||
<td>{{ period_end|date:"F j, Y" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You now have full access to all features included in your plan. Start exploring what IGNY8 can do for you!</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing IGNY8!<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Subscription Renewal Reminder{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Subscription Renewal Reminder</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<p>Your subscription will be renewed in <strong>{{ days_until_renewal }} days</strong>.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Subscription Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td><strong>{{ plan_name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Renewal Date</td>
|
||||
<td>{{ renewal_date|date:"F j, Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td><strong>{{ currency }} {{ amount }}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>Your payment method will be charged automatically on the renewal date.</p>
|
||||
|
||||
<p>To manage your subscription or update your payment details:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ subscription_url }}" class="button">Manage Subscription</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
30
backend/igny8_core/templates/emails/welcome.html
Normal file
30
backend/igny8_core/templates/emails/welcome.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Welcome to IGNY8{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Welcome to IGNY8!</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<p>We're excited to have you on board! Your account <strong>{{ account_name }}</strong> is ready to go.</p>
|
||||
|
||||
<div class="success-box">
|
||||
<strong>What's next?</strong>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>Explore your dashboard</li>
|
||||
<li>Set up your first project</li>
|
||||
<li>Connect your integrations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ login_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>If you have any questions, our support team is here to help. Just reply to this email or visit our help center.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user