Phase 3 & Phase 4 - Completed

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

View File

@@ -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'],
)

View 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()

View 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()

View File

@@ -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'),
]

View File

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

View File

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

View 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>&copy; {{ 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View 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 %}

View File

@@ -16,6 +16,7 @@ docker>=7.0.0
drf-spectacular>=0.27.0
stripe>=7.10.0
anthropic>=0.25.0
resend>=0.7.0
# Django Admin Enhancements
django-unfold==0.73.1