From 909ed1cb17e7896d731c0550b401fd3d8093dffc Mon Sep 17 00:00:00 2001
From: "IGNY8 VPS (Salman)"
Date: Wed, 7 Jan 2026 00:57:26 +0000
Subject: [PATCH] Phase 3 & Phase 4 - Completed
---
.../billing/services/email_service.py | 781 +++++++++++++---
.../billing/services/paypal_service.py | 679 ++++++++++++++
.../billing/services/stripe_service.py | 627 +++++++++++++
backend/igny8_core/business/billing/urls.py | 31 +
.../business/billing/views/paypal_views.py | 868 ++++++++++++++++++
.../business/billing/views/stripe_views.py | 701 ++++++++++++++
backend/igny8_core/templates/emails/base.html | 112 +++
.../templates/emails/email_verification.html | 33 +
.../templates/emails/low_credits.html | 39 +
.../templates/emails/password_reset.html | 31 +
.../templates/emails/payment_approved.html | 47 +
.../emails/payment_confirmation.html | 45 +
.../templates/emails/payment_failed.html | 39 +
.../templates/emails/payment_rejected.html | 49 +
.../templates/emails/refund_notification.html | 49 +
.../emails/subscription_activated.html | 41 +
.../emails/subscription_renewal.html | 41 +
.../igny8_core/templates/emails/welcome.html | 30 +
backend/requirements.txt | 1 +
docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md | 648 +++++++++++++
docs/plans/FINAL-PRELAUNCH.md | 34 +-
docs/plans/THIRD-PARTY-INTEGRATIONS-PLAN.md | 171 ++--
.../billing/PaymentGatewaySelector.tsx | 192 ++++
.../src/pages/account/PlansAndBillingPage.tsx | 189 +++-
frontend/src/services/billing.api.ts | 286 ++++++
25 files changed, 5549 insertions(+), 215 deletions(-)
create mode 100644 backend/igny8_core/business/billing/services/paypal_service.py
create mode 100644 backend/igny8_core/business/billing/services/stripe_service.py
create mode 100644 backend/igny8_core/business/billing/views/paypal_views.py
create mode 100644 backend/igny8_core/business/billing/views/stripe_views.py
create mode 100644 backend/igny8_core/templates/emails/base.html
create mode 100644 backend/igny8_core/templates/emails/email_verification.html
create mode 100644 backend/igny8_core/templates/emails/low_credits.html
create mode 100644 backend/igny8_core/templates/emails/password_reset.html
create mode 100644 backend/igny8_core/templates/emails/payment_approved.html
create mode 100644 backend/igny8_core/templates/emails/payment_confirmation.html
create mode 100644 backend/igny8_core/templates/emails/payment_failed.html
create mode 100644 backend/igny8_core/templates/emails/payment_rejected.html
create mode 100644 backend/igny8_core/templates/emails/refund_notification.html
create mode 100644 backend/igny8_core/templates/emails/subscription_activated.html
create mode 100644 backend/igny8_core/templates/emails/subscription_renewal.html
create mode 100644 backend/igny8_core/templates/emails/welcome.html
create mode 100644 docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md
create mode 100644 frontend/src/components/billing/PaymentGatewaySelector.tsx
diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py
index c73a575a..7b98b634 100644
--- a/backend/igny8_core/business/billing/services/email_service.py
+++ b/backend/igny8_core/business/billing/services/email_service.py
@@ -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'],
+ )
diff --git a/backend/igny8_core/business/billing/services/paypal_service.py b/backend/igny8_core/business/billing/services/paypal_service.py
new file mode 100644
index 00000000..e310cbc6
--- /dev/null
+++ b/backend/igny8_core/business/billing/services/paypal_service.py
@@ -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()
diff --git a/backend/igny8_core/business/billing/services/stripe_service.py b/backend/igny8_core/business/billing/services/stripe_service.py
new file mode 100644
index 00000000..96fecbcd
--- /dev/null
+++ b/backend/igny8_core/business/billing/services/stripe_service.py
@@ -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()
diff --git a/backend/igny8_core/business/billing/urls.py b/backend/igny8_core/business/billing/urls.py
index e9f51af6..89f88028 100644
--- a/backend/igny8_core/business/billing/urls.py
+++ b/backend/igny8_core/business/billing/urls.py
@@ -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'),
]
diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py
new file mode 100644
index 00000000..6efee027
--- /dev/null
+++ b/backend/igny8_core/business/billing/views/paypal_views.py
@@ -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
diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py
new file mode 100644
index 00000000..09c6d491
--- /dev/null
+++ b/backend/igny8_core/business/billing/views/stripe_views.py
@@ -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}")
diff --git a/backend/igny8_core/templates/emails/base.html b/backend/igny8_core/templates/emails/base.html
new file mode 100644
index 00000000..bf771356
--- /dev/null
+++ b/backend/igny8_core/templates/emails/base.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+ {% block title %}IGNY8{% endblock %}
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/backend/igny8_core/templates/emails/email_verification.html b/backend/igny8_core/templates/emails/email_verification.html
new file mode 100644
index 00000000..471a34d9
--- /dev/null
+++ b/backend/igny8_core/templates/emails/email_verification.html
@@ -0,0 +1,33 @@
+{% extends "emails/base.html" %}
+{% block title %}Verify Your Email{% endblock %}
+
+{% block content %}
+Verify Your Email Address
+
+Hi {{ user_name }},
+
+Thanks for signing up! Please verify your email address by clicking the button below:
+
+
+ Verify Email
+
+
+
+
Why verify?
+
+ Secure your account
+ Receive important notifications
+ Access all features
+
+
+
+If you didn't create an account, you can safely ignore this email.
+
+If the button doesn't work, copy and paste this link into your browser:
+{{ verification_url }}
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/low_credits.html b/backend/igny8_core/templates/emails/low_credits.html
new file mode 100644
index 00000000..b8b4381e
--- /dev/null
+++ b/backend/igny8_core/templates/emails/low_credits.html
@@ -0,0 +1,39 @@
+{% extends "emails/base.html" %}
+{% block title %}Low Credits Warning{% endblock %}
+
+{% block content %}
+Low Credits Warning
+
+Hi {{ account_name }},
+
+
+ Heads up! Your credit balance is running low.
+
+
+
+
Credit Status
+
+
+ Current Balance
+ {{ current_credits }} credits
+
+
+ Warning Threshold
+ {{ threshold }} credits
+
+
+
+
+To avoid service interruption, we recommend topping up your credits soon.
+
+
+ Buy More Credits
+
+
+Tip: Consider enabling auto-top-up in your account settings to never run out of credits again!
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/password_reset.html b/backend/igny8_core/templates/emails/password_reset.html
new file mode 100644
index 00000000..c6ef2939
--- /dev/null
+++ b/backend/igny8_core/templates/emails/password_reset.html
@@ -0,0 +1,31 @@
+{% extends "emails/base.html" %}
+{% block title %}Reset Your Password{% endblock %}
+
+{% block content %}
+Reset Your Password
+
+Hi {{ user_name }},
+
+We received a request to reset your password. Click the button below to create a new password:
+
+
+ Reset Password
+
+
+
+
Important:
+
+ This link expires in 24 hours
+ If you didn't request this, you can safely ignore this email
+ Your password won't change until you create a new one
+
+
+
+If the button doesn't work, copy and paste this link into your browser:
+{{ reset_url }}
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_approved.html b/backend/igny8_core/templates/emails/payment_approved.html
new file mode 100644
index 00000000..fcb07e99
--- /dev/null
+++ b/backend/igny8_core/templates/emails/payment_approved.html
@@ -0,0 +1,47 @@
+{% extends "emails/base.html" %}
+{% block title %}Payment Approved{% endblock %}
+
+{% block content %}
+Payment Approved!
+
+Hi {{ account_name }},
+
+
+ Great news! Your payment has been approved and your account is now active.
+
+
+
+
Payment Details
+
+
+ Invoice
+ #{{ invoice_number }}
+
+
+ Amount
+ {{ currency }} {{ amount }}
+
+ {% if plan_name and plan_name != 'N/A' %}
+
+ Plan
+ {{ plan_name }}
+
+ {% endif %}
+
+ Approved
+ {{ approved_at|date:"F j, Y H:i" }}
+
+
+
+
+You can now access all features of your plan. Log in to get started!
+
+
+ Go to Dashboard
+
+
+
+Thank you for choosing IGNY8!
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_confirmation.html b/backend/igny8_core/templates/emails/payment_confirmation.html
new file mode 100644
index 00000000..3224e9b8
--- /dev/null
+++ b/backend/igny8_core/templates/emails/payment_confirmation.html
@@ -0,0 +1,45 @@
+{% extends "emails/base.html" %}
+{% block title %}Payment Confirmation Received{% endblock %}
+
+{% block content %}
+Payment Confirmation Received
+
+Hi {{ account_name }},
+
+We have received your payment confirmation and it is now being reviewed by our team.
+
+
+
Payment Details
+
+
+ Invoice
+ #{{ invoice_number }}
+
+
+ Amount
+ {{ currency }} {{ amount }}
+
+
+ Payment Method
+ {{ payment_method }}
+
+ {% if manual_reference %}
+
+ Reference
+ {{ manual_reference }}
+
+ {% endif %}
+
+ Submitted
+ {{ created_at|date:"F j, Y H:i" }}
+
+
+
+
+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.
+
+
+Thank you for your patience,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_failed.html b/backend/igny8_core/templates/emails/payment_failed.html
new file mode 100644
index 00000000..4c00d0b3
--- /dev/null
+++ b/backend/igny8_core/templates/emails/payment_failed.html
@@ -0,0 +1,39 @@
+{% extends "emails/base.html" %}
+{% block title %}Payment Failed{% endblock %}
+
+{% block content %}
+Payment Failed - Action Required
+
+Hi {{ account_name }},
+
+
+ We were unable to process your payment for the {{ plan_name }} plan.
+
+
+
+
Details
+
+
+ Plan
+ {{ plan_name }}
+
+
+ Reason
+ {{ failure_reason }}
+
+
+
+
+Please update your payment method to continue your subscription and avoid service interruption.
+
+
+ Update Payment Method
+
+
+If you need assistance, please contact our support team by replying to this email.
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_rejected.html b/backend/igny8_core/templates/emails/payment_rejected.html
new file mode 100644
index 00000000..69384885
--- /dev/null
+++ b/backend/igny8_core/templates/emails/payment_rejected.html
@@ -0,0 +1,49 @@
+{% extends "emails/base.html" %}
+{% block title %}Payment Declined{% endblock %}
+
+{% block content %}
+Payment Declined
+
+Hi {{ account_name }},
+
+
+ Unfortunately, we were unable to approve your payment for Invoice #{{ invoice_number }} .
+
+
+
+
Details
+
+
+ Invoice
+ #{{ invoice_number }}
+
+
+ Amount
+ {{ currency }} {{ amount }}
+
+ {% if manual_reference %}
+
+ Reference
+ {{ manual_reference }}
+
+ {% endif %}
+
+ Reason
+ {{ reason }}
+
+
+
+
+You can retry your payment by logging into your account:
+
+
+ Retry Payment
+
+
+If you believe this is an error or have questions, please contact our support team by replying to this email.
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/refund_notification.html b/backend/igny8_core/templates/emails/refund_notification.html
new file mode 100644
index 00000000..85baef4e
--- /dev/null
+++ b/backend/igny8_core/templates/emails/refund_notification.html
@@ -0,0 +1,49 @@
+{% extends "emails/base.html" %}
+{% block title %}Refund Processed{% endblock %}
+
+{% block content %}
+Refund Processed
+
+Hi {{ user_name }},
+
+
+ Your refund has been processed successfully.
+
+
+
+
Refund Details
+
+
+ Invoice
+ #{{ invoice_number }}
+
+
+ Original Amount
+ {{ currency }} {{ original_amount }}
+
+
+ Refund Amount
+ {{ currency }} {{ refund_amount }}
+
+
+ Reason
+ {{ reason }}
+
+ {% if refunded_at %}
+
+ Processed
+ {{ refunded_at|date:"F j, Y H:i" }}
+
+ {% endif %}
+
+
+
+The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.
+
+If you have any questions about this refund, please contact our support team by replying to this email.
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/subscription_activated.html b/backend/igny8_core/templates/emails/subscription_activated.html
new file mode 100644
index 00000000..bdd0fe65
--- /dev/null
+++ b/backend/igny8_core/templates/emails/subscription_activated.html
@@ -0,0 +1,41 @@
+{% extends "emails/base.html" %}
+{% block title %}Subscription Activated{% endblock %}
+
+{% block content %}
+Your Subscription is Active!
+
+Hi {{ account_name }},
+
+
+ Your {{ plan_name }} subscription is now active!
+
+
+
+
What's Included
+
+
+ Plan
+ {{ plan_name }}
+
+
+ Credits
+ {{ included_credits }} credits added to your account
+
+
+ Active Until
+ {{ period_end|date:"F j, Y" }}
+
+
+
+
+You now have full access to all features included in your plan. Start exploring what IGNY8 can do for you!
+
+
+ Go to Dashboard
+
+
+
+Thank you for choosing IGNY8!
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/subscription_renewal.html b/backend/igny8_core/templates/emails/subscription_renewal.html
new file mode 100644
index 00000000..5352651d
--- /dev/null
+++ b/backend/igny8_core/templates/emails/subscription_renewal.html
@@ -0,0 +1,41 @@
+{% extends "emails/base.html" %}
+{% block title %}Subscription Renewal Reminder{% endblock %}
+
+{% block content %}
+Subscription Renewal Reminder
+
+Hi {{ account_name }},
+
+Your subscription will be renewed in {{ days_until_renewal }} days .
+
+
+
Subscription Details
+
+
+ Plan
+ {{ plan_name }}
+
+
+ Renewal Date
+ {{ renewal_date|date:"F j, Y" }}
+
+
+ Amount
+ {{ currency }} {{ amount }}
+
+
+
+
+Your payment method will be charged automatically on the renewal date.
+
+To manage your subscription or update your payment details:
+
+
+ Manage Subscription
+
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/welcome.html b/backend/igny8_core/templates/emails/welcome.html
new file mode 100644
index 00000000..961aa3a6
--- /dev/null
+++ b/backend/igny8_core/templates/emails/welcome.html
@@ -0,0 +1,30 @@
+{% extends "emails/base.html" %}
+{% block title %}Welcome to IGNY8{% endblock %}
+
+{% block content %}
+Welcome to IGNY8!
+
+Hi {{ user_name }},
+
+We're excited to have you on board! Your account {{ account_name }} is ready to go.
+
+
+
What's next?
+
+ Explore your dashboard
+ Set up your first project
+ Connect your integrations
+
+
+
+
+ Go to Dashboard
+
+
+If you have any questions, our support team is here to help. Just reply to this email or visit our help center.
+
+
+Best regards,
+The IGNY8 Team
+
+{% endblock %}
diff --git a/backend/requirements.txt b/backend/requirements.txt
index f233346c..3c806bd3 100755
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md b/docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md
new file mode 100644
index 00000000..04352891
--- /dev/null
+++ b/docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md
@@ -0,0 +1,648 @@
+# Django Admin Access Guide - Payment & Email Integration Settings
+
+**Date:** January 7, 2026
+**Purpose:** Guide to configure Stripe, PayPal, and Resend credentials via Django Admin
+
+---
+
+## Table of Contents
+
+1. [Accessing Django Admin](#1-accessing-django-admin)
+2. [Integration Providers Settings](#2-integration-providers-settings)
+3. [Stripe Configuration](#3-stripe-configuration)
+4. [PayPal Configuration](#4-paypal-configuration)
+5. [Resend Configuration](#5-resend-configuration)
+6. [Plan & Pricing Configuration](#6-plan--pricing-configuration)
+7. [Credit Packages Configuration](#7-credit-packages-configuration)
+8. [Testing Checklist](#8-testing-checklist)
+
+---
+
+## 1. Accessing Django Admin
+
+### 1.1 URL Access
+
+**Local Development:**
+```
+http://localhost:8000/admin/
+```
+
+**Staging/Production:**
+```
+https://api.igny8.com/admin/
+```
+
+### 1.2 Login
+
+Use your superuser credentials:
+- **Username:** (your admin username)
+- **Password:** (your admin password)
+
+**Create Superuser (if needed):**
+```bash
+cd /data/app/igny8/backend
+python manage.py createsuperuser
+```
+
+---
+
+## 2. Integration Providers Settings
+
+### 2.1 Navigating to Integration Providers
+
+1. Log in to Django Admin
+2. Look for **"MODULES"** section (or similar grouping)
+3. Click on **"System" → "Integration providers"**
+
+**Direct URL Path:**
+```
+/admin/system/integrationprovider/
+```
+
+### 2.2 Pre-seeded Providers
+
+You should see these providers already created:
+
+| Provider ID | Display Name | Type | Status |
+|-------------|--------------|------|--------|
+| `stripe` | Stripe | payment | Active (sandbox) |
+| `paypal` | PayPal | payment | Active (sandbox) |
+| `resend` | Resend | email | Active |
+| `openai` | OpenAI | ai | Active |
+| `anthropic` | Anthropic | ai | Active |
+| `google` | Google | ai | Active |
+| `runware` | Runware | ai | Active |
+| `cloudflare_r2` | Cloudflare R2 | storage | Active |
+
+---
+
+## 3. Stripe Configuration
+
+### 3.1 Getting Stripe Credentials
+
+#### Step 1: Login to Stripe Dashboard
+Go to [dashboard.stripe.com](https://dashboard.stripe.com)
+
+#### Step 2: Get API Keys
+
+**Test Mode (Sandbox):**
+1. Toggle to "Test mode" in top-right
+2. Go to **Developers → API keys**
+3. Copy:
+ - **Publishable key** (starts with `pk_test_...`)
+ - **Secret key** (starts with `sk_test_...`)
+
+**Live Mode (Production):**
+1. Toggle to "Live mode"
+2. Go to **Developers → API keys**
+3. Copy:
+ - **Publishable key** (starts with `pk_live_...`)
+ - **Secret key** (starts with `sk_live_...`)
+
+#### Step 3: Configure Webhook
+
+1. Go to **Developers → Webhooks**
+2. Click **"Add endpoint"**
+3. Enter endpoint URL:
+ ```
+ Test: https://api-staging.igny8.com/api/v1/billing/webhooks/stripe/
+ Live: https://api.igny8.com/api/v1/billing/webhooks/stripe/
+ ```
+4. Select events to listen for:
+ - `checkout.session.completed`
+ - `invoice.paid`
+ - `invoice.payment_failed`
+ - `customer.subscription.updated`
+ - `customer.subscription.deleted`
+5. Click **"Add endpoint"**
+6. Copy the **Signing secret** (starts with `whsec_...`)
+
+#### Step 4: Create Products and Prices
+
+1. Go to **Products → Add product**
+2. Create these products:
+
+**Starter Plan**
+- Name: `Starter Plan`
+- Description: `Basic plan for small projects`
+- Pricing: `$99.00 / month`
+- Copy the **Price ID** (starts with `price_...`)
+
+**Growth Plan**
+- Name: `Growth Plan`
+- Description: `For growing businesses`
+- Pricing: `$199.00 / month`
+- Copy the **Price ID**
+
+**Scale Plan**
+- Name: `Scale Plan`
+- Description: `For large enterprises`
+- Pricing: `$299.00 / month`
+- Copy the **Price ID**
+
+### 3.2 Adding to Django Admin
+
+1. Go to Django Admin → **System → Integration providers**
+2. Click on **"stripe"**
+3. Fill in the fields:
+
+```
+Provider ID: stripe (already set)
+Display name: Stripe (already set)
+Provider type: payment (already set)
+
+API key: pk_test_xxxxxxxxxxxxx (or pk_live_ for production)
+API secret: sk_test_xxxxxxxxxxxxx (or sk_live_ for production)
+Webhook secret: whsec_xxxxxxxxxxxxx
+
+API endpoint: [leave empty - uses default]
+
+Config (JSON):
+{
+ "currency": "usd",
+ "payment_methods": ["card"],
+ "billing_portal_enabled": true
+}
+
+✅ Is active: Checked
+✅ Is sandbox: Checked (for test mode) / Unchecked (for live mode)
+```
+
+4. Click **"Save"**
+
+### 3.3 Update Plan Models with Stripe Price IDs
+
+1. Go to Django Admin → **Auth → Plans**
+2. Edit each plan:
+
+**Starter Plan:**
+- Stripe price id: `price_xxxxxxxxxxxxx` (from Stripe dashboard)
+- Stripe product id: `prod_xxxxxxxxxxxxx` (optional)
+
+**Growth Plan:**
+- Stripe price id: `price_xxxxxxxxxxxxx`
+
+**Scale Plan:**
+- Stripe price id: `price_xxxxxxxxxxxxx`
+
+3. Save each plan
+
+---
+
+## 4. PayPal Configuration
+
+### 4.1 Getting PayPal Credentials
+
+#### Step 1: Login to PayPal Developer Dashboard
+Go to [developer.paypal.com](https://developer.paypal.com)
+
+#### Step 2: Create an App
+
+1. Go to **My Apps & Credentials**
+2. Select **Sandbox** (for testing) or **Live** (for production)
+3. Click **"Create App"**
+4. Enter app name: `IGNY8 Payment Integration`
+5. Click **"Create App"**
+6. Copy:
+ - **Client ID** (starts with `AY...` or similar)
+ - **Secret** (click "Show" to reveal)
+
+#### Step 3: Configure Webhooks
+
+1. In your app settings, scroll to **"WEBHOOKS"**
+2. Click **"Add Webhook"**
+3. Enter webhook URL:
+ ```
+ Sandbox: https://api-staging.igny8.com/api/v1/billing/webhooks/paypal/
+ Live: https://api.igny8.com/api/v1/billing/webhooks/paypal/
+ ```
+4. Select event types:
+ - `CHECKOUT.ORDER.APPROVED`
+ - `PAYMENT.CAPTURE.COMPLETED`
+ - `PAYMENT.CAPTURE.DENIED`
+ - `BILLING.SUBSCRIPTION.ACTIVATED`
+ - `BILLING.SUBSCRIPTION.CANCELLED`
+5. Click **"Save"**
+6. Copy the **Webhook ID** (starts with `WH-...`)
+
+#### Step 4: Create Subscription Plans (Optional)
+
+If you want PayPal subscriptions:
+1. Go to **Products** in PayPal dashboard
+2. Create subscription plans matching your Stripe plans
+3. Copy the **Plan IDs**
+
+### 4.2 Adding to Django Admin
+
+1. Go to Django Admin → **System → Integration providers**
+2. Click on **"paypal"**
+3. Fill in the fields:
+
+```
+Provider ID: paypal (already set)
+Display name: PayPal (already set)
+Provider type: payment (already set)
+
+API key: AYxxxxxxxxxxx (Client ID)
+API secret: ELxxxxxxxxxxx (Secret)
+Webhook secret: [leave empty - not used by PayPal]
+
+API endpoint:
+ Sandbox: https://api-m.sandbox.paypal.com
+ Live: https://api-m.paypal.com
+
+Config (JSON):
+{
+ "currency": "USD",
+ "webhook_id": "WH-xxxxxxxxxxxxx",
+ "return_url": "https://app.igny8.com/account/plans?paypal=success",
+ "cancel_url": "https://app.igny8.com/account/plans?paypal=cancel"
+}
+
+✅ Is active: Checked
+✅ Is sandbox: Checked (for sandbox) / Unchecked (for live)
+```
+
+4. Click **"Save"**
+
+---
+
+## 5. Resend Configuration
+
+### 5.1 Getting Resend API Key
+
+#### Step 1: Login to Resend
+Go to [resend.com](https://resend.com)
+
+#### Step 2: Create API Key
+
+1. Go to **API Keys**
+2. Click **"Create API Key"**
+3. Enter name: `IGNY8 Production` (or `IGNY8 Development`)
+4. Select permission: **"Sending access"**
+5. Click **"Add"**
+6. Copy the API key (starts with `re_...`)
+7. **Save it securely** - you won't see it again!
+
+#### Step 3: Verify Your Domain
+
+1. Go to **Domains**
+2. Click **"Add Domain"**
+3. Enter your domain: `igny8.com`
+4. Follow instructions to add DNS records:
+ - **DKIM Record** (TXT)
+ - **SPF Record** (TXT)
+ - **DMARC Record** (TXT)
+5. Click **"Verify"**
+6. Wait for verification (can take a few minutes to 24 hours)
+
+### 5.2 Adding to Django Admin
+
+1. Go to Django Admin → **System → Integration providers**
+2. Click on **"resend"**
+3. Fill in the fields:
+
+```
+Provider ID: resend (already set)
+Display name: Resend (already set)
+Provider type: email (already set)
+
+API key: re_xxxxxxxxxxxxx
+API secret: [leave empty]
+Webhook secret: [leave empty]
+API endpoint: [leave empty - uses default]
+
+Config (JSON):
+{
+ "from_email": "noreply@igny8.com",
+ "from_name": "IGNY8",
+ "reply_to": "support@igny8.com"
+}
+
+✅ Is active: Checked
+✅ Is sandbox: Unchecked (Resend doesn't have sandbox mode)
+```
+
+4. Click **"Save"**
+
+### 5.3 Testing Email Delivery
+
+After configuring Resend, test email delivery:
+
+```bash
+cd /data/app/igny8/backend
+python manage.py shell
+```
+
+```python
+from igny8_core.business.billing.services.email_service import get_email_service
+
+service = get_email_service()
+service.send_transactional(
+ to='your-email@example.com',
+ subject='Test Email from IGNY8',
+ html='Test Email If you receive this, Resend is configured correctly!
',
+ text='Test Email. If you receive this, Resend is configured correctly!'
+)
+```
+
+---
+
+## 6. Plan & Pricing Configuration
+
+### 6.1 Viewing Plans
+
+1. Go to Django Admin → **Auth → Plans**
+2. You should see existing plans:
+ - Free Plan
+ - Starter Plan
+ - Growth Plan
+ - Scale Plan
+ - Enterprise Plan
+
+### 6.2 Editing Plan Details
+
+For each plan:
+
+```
+Name: Starter Plan
+Description: Perfect for small projects
+Price: 99.00
+Billing period: monthly
+Included credits: 5000
+Is active: ✅
+
+Stripe price id: price_xxxxxxxxxxxxx (from Stripe dashboard)
+Stripe product id: prod_xxxxxxxxxxxxx (optional)
+Paypal plan id: P-xxxxxxxxxxxxx (if using PayPal subscriptions)
+
+Feature limits:
+Max keywords: 50
+Max articles per month: 100
+Max team members: 3
+Max websites: 1
+```
+
+### 6.3 Creating Custom Plans
+
+1. Click **"Add plan"**
+2. Fill in all fields
+3. Make sure to set:
+ - ✅ `is_active` = True (to show to users)
+ - Stripe price ID (from Stripe dashboard)
+ - Included credits (monthly allocation)
+4. Click **"Save"**
+
+---
+
+## 7. Credit Packages Configuration
+
+### 7.1 Viewing Credit Packages
+
+1. Go to Django Admin → **Billing → Credit packages**
+2. You should see existing packages:
+ - Starter: 500 credits @ $9.99
+ - Value: 2,000 credits @ $29.99
+ - Pro: 5,000 credits @ $59.99
+ - Enterprise: 15,000 credits @ $149.99
+
+### 7.2 Editing Credit Packages
+
+For each package:
+
+```
+Name: Value Package
+Description: Best value for money
+Credits: 2000
+Price: 29.99
+Display order: 2
+
+✅ Is active: Checked
+✅ Is featured: Checked (to highlight on UI)
+
+Stripe product id: prod_xxxxxxxxxxxxx (optional - for tracking)
+Paypal product id: (optional)
+```
+
+### 7.3 Creating Custom Credit Packages
+
+1. Click **"Add credit package"**
+2. Fill in:
+ - Name: e.g., "Black Friday Special"
+ - Credits: e.g., 10000
+ - Price: e.g., 79.99
+ - Description: "Limited time offer!"
+3. Check ✅ `is_active`
+4. Check ✅ `is_featured` (optional)
+5. Click **"Save"**
+
+---
+
+## 8. Testing Checklist
+
+### 8.1 Verify Integration Provider Settings
+
+Go to Admin → **System → Integration providers**
+
+- [ ] **Stripe** - API keys added, webhook secret added, is_active=True
+- [ ] **PayPal** - Client ID/Secret added, webhook ID in config, is_active=True
+- [ ] **Resend** - API key added, domain verified, is_active=True
+
+### 8.2 Test Stripe Integration
+
+1. **Get Config Endpoint:**
+ ```bash
+ curl -X GET https://api.igny8.com/api/v1/billing/stripe/config/ \
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"
+ ```
+ Should return publishable key and sandbox status
+
+2. **Test Checkout Session:**
+ - Go to frontend: `/account/plans`
+ - Select "Stripe" as payment method
+ - Click "Subscribe" on a plan
+ - Should redirect to Stripe Checkout
+ - Complete test payment with card: `4242 4242 4242 4242`
+ - Should receive webhook and activate subscription
+
+3. **Test Billing Portal:**
+ - Click "Manage Subscription" button
+ - Should redirect to Stripe Billing Portal
+ - Can cancel/update subscription
+
+### 8.3 Test PayPal Integration
+
+1. **Get Config Endpoint:**
+ ```bash
+ curl -X GET https://api.igny8.com/api/v1/billing/paypal/config/ \
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"
+ ```
+ Should return client_id and sandbox status
+
+2. **Test Credit Purchase:**
+ - Go to frontend: `/account/usage`
+ - Select "PayPal" as payment method
+ - Click "Buy Credits" on a package
+ - Should redirect to PayPal
+ - Login with sandbox account
+ - Complete payment
+ - Should capture order and add credits
+
+### 8.4 Test Email Service
+
+1. **Test Welcome Email:**
+ ```bash
+ cd /data/app/igny8/backend
+ python manage.py shell
+ ```
+ ```python
+ from igny8_core.auth.models import User, Account
+ from igny8_core.business.billing.services.email_service import send_welcome_email
+
+ user = User.objects.first()
+ account = user.account
+ send_welcome_email(user, account)
+ ```
+ Check your inbox for welcome email
+
+2. **Test Payment Confirmation:**
+ - Complete a test payment (Stripe or PayPal)
+ - Should receive payment confirmation email
+ - Check email content and formatting
+
+### 8.5 Test Webhooks
+
+1. **Check Webhook Logs:**
+ ```bash
+ cd /data/app/igny8/backend
+ tail -f logs/django.log | grep webhook
+ ```
+
+2. **Trigger Webhook Events:**
+ - **Stripe:** Complete test checkout, then check webhook logs
+ - **PayPal:** Complete test payment, then check webhook logs
+
+3. **Verify Webhook Processing:**
+ - Subscription should be activated
+ - Credits should be added
+ - Email notifications should be sent
+
+---
+
+## Quick Reference: Admin URLs
+
+```
+# Main sections
+/admin/system/integrationprovider/ # All integration providers
+/admin/auth/plan/ # Plans and pricing
+/admin/billing/creditpackage/ # Credit packages
+/admin/billing/payment/ # Payment history
+/admin/billing/invoice/ # Invoices
+/admin/auth/subscription/ # Active subscriptions
+/admin/billing/credittransaction/ # Credit transaction history
+
+# Specific provider configs
+/admin/system/integrationprovider/stripe/change/
+/admin/system/integrationprovider/paypal/change/
+/admin/system/integrationprovider/resend/change/
+```
+
+---
+
+## Security Best Practices
+
+### Never Commit API Keys
+- ❌ Don't add API keys to code
+- ❌ Don't commit `.env` files
+- ✅ Use Django Admin to store credentials
+- ✅ Use IntegrationProvider model (encrypted in DB)
+
+### Use Environment-Specific Keys
+- **Development:** Use Stripe test mode, PayPal sandbox
+- **Staging:** Use separate test credentials
+- **Production:** Use live credentials ONLY in production
+
+### Regular Key Rotation
+- Rotate API keys every 90 days
+- Rotate webhook secrets if compromised
+- Keep backup of old keys during rotation
+
+### Monitor Webhook Security
+- Verify webhook signatures always
+- Log all webhook attempts
+- Alert on failed signature verification
+
+---
+
+## Troubleshooting
+
+### Stripe Issues
+
+**"No such customer"**
+- Check if `stripe_customer_id` is set on Account model
+- Clear the field and let system recreate customer
+
+**"Invalid API Key"**
+- Verify API key in IntegrationProvider
+- Check if using test key in live mode (or vice versa)
+
+**Webhook not working**
+- Check webhook URL in Stripe dashboard
+- Verify webhook secret in IntegrationProvider
+- Check server logs for errors
+
+### PayPal Issues
+
+**"Invalid client credentials"**
+- Verify Client ID and Secret in IntegrationProvider
+- Make sure using sandbox credentials for sandbox mode
+
+**"Webhook verification failed"**
+- Check webhook_id in config JSON
+- Verify webhook URL in PayPal dashboard
+
+**Order capture fails**
+- Check order status (must be APPROVED)
+- Verify order hasn't already been captured
+
+### Resend Issues
+
+**"Invalid API key"**
+- Verify API key starts with `re_`
+- Create new API key if needed
+
+**"Domain not verified"**
+- Check DNS records in domain provider
+- Wait up to 24 hours for DNS propagation
+- Use Resend dashboard to verify domain status
+
+**Emails not delivered**
+- Check Resend dashboard logs
+- Verify from_email domain is verified
+- Check spam folder
+
+---
+
+## Next Steps
+
+After configuring all providers:
+
+1. ✅ Test all payment flows in sandbox mode
+2. ✅ Test email delivery
+3. ✅ Verify webhook processing
+4. ✅ Test frontend payment gateway selection
+5. ✅ Switch to production credentials when ready to go live
+
+For production deployment, update:
+- `is_sandbox` = False for Stripe
+- `is_sandbox` = False for PayPal
+- `api_endpoint` = production URLs
+- Use live API keys for all providers
+
+---
+
+**Support:** If you encounter issues, check Django logs:
+```bash
+cd /data/app/igny8/backend
+tail -f logs/django.log
+```
diff --git a/docs/plans/FINAL-PRELAUNCH.md b/docs/plans/FINAL-PRELAUNCH.md
index c7458b05..2eaec42f 100644
--- a/docs/plans/FINAL-PRELAUNCH.md
+++ b/docs/plans/FINAL-PRELAUNCH.md
@@ -144,20 +144,20 @@ grep -rn " **Note:** All credentials are stored in the `IntegrationProvider` model and can be configured via Django Admin. The implementation supports both sandbox (testing) and production environments.
+
### 5.1 Stripe Credentials
| Item | Value | Status |
|------|-------|--------|
-| Publishable Key (Live) | `pk_live_...` | ⬜ Needed |
-| Secret Key (Live) | `sk_live_...` | ⬜ Needed |
-| Webhook Signing Secret | `whsec_...` | ⬜ Needed |
-| Products/Prices Created | Plan IDs | ⬜ Needed |
+| Publishable Key (Live) | `pk_live_...` | ⬜ Add to IntegrationProvider |
+| Secret Key (Live) | `sk_live_...` | ⬜ Add to IntegrationProvider |
+| Webhook Signing Secret | `whsec_...` | ⬜ Add to IntegrationProvider |
+| Products/Prices Created | Plan IDs | ⬜ Create in Stripe Dashboard |
+| **Code Implementation** | — | ✅ **Complete** |
**Stripe Products to Create:**
1. **Starter Plan** - $99/mo - `price_starter_monthly`
@@ -1128,18 +1148,20 @@ def send_low_credits_warning(account, current_credits, threshold):
| Item | Value | Status |
|------|-------|--------|
-| Client ID (Live) | `AY...` | ⬜ Needed |
-| Client Secret (Live) | `EL...` | ⬜ Needed |
-| Webhook ID | `WH-...` | ⬜ Needed |
-| Subscription Plans Created | PayPal Plan IDs | ⬜ Needed |
+| Client ID (Live) | `AY...` | ⬜ Add to IntegrationProvider |
+| Client Secret (Live) | `EL...` | ⬜ Add to IntegrationProvider |
+| Webhook ID | `WH-...` | ⬜ Add to IntegrationProvider config |
+| Subscription Plans Created | PayPal Plan IDs | ⬜ Create in PayPal Dashboard |
+| **Code Implementation** | — | ✅ **Complete** |
### 5.3 Resend Credentials
| Item | Value | Status |
|------|-------|--------|
-| API Key | `re_...` | ⬜ Needed |
-| Domain Verified | igny8.com | ⬜ Needed |
-| DNS Records Added | DKIM, SPF | ⬜ Needed |
+| API Key | `re_...` | ⬜ Add to IntegrationProvider |
+| Domain Verified | igny8.com | ⬜ Configure in Resend Dashboard |
+| DNS Records Added | DKIM, SPF | ⬜ Add to DNS provider |
+| **Code Implementation** | — | ✅ **Complete** |
### 5.4 Brevo Credentials (Future)
@@ -1152,25 +1174,25 @@ def send_low_credits_warning(account, current_credits, threshold):
## 6. Implementation Order
-### Phase 1: Backend Setup (2-3 days)
+### Phase 1: Backend Setup ✅ COMPLETE
-1. **Add dependencies:**
+1. **✅ Add dependencies:**
```bash
pip install resend>=0.7.0
# PayPal uses requests (already installed)
# Stripe already installed
```
-2. **Create service files:**
- - `backend/igny8_core/business/billing/services/stripe_service.py`
- - `backend/igny8_core/business/billing/services/paypal_service.py`
- - Update `backend/igny8_core/business/billing/services/email_service.py`
+2. **✅ Create service files:**
+ - ✅ `backend/igny8_core/business/billing/services/stripe_service.py`
+ - ✅ `backend/igny8_core/business/billing/services/paypal_service.py`
+ - ✅ Update `backend/igny8_core/business/billing/services/email_service.py`
-3. **Create view files:**
- - `backend/igny8_core/business/billing/views/stripe_views.py`
- - `backend/igny8_core/business/billing/views/paypal_views.py`
+3. **✅ Create view files:**
+ - ✅ `backend/igny8_core/business/billing/views/stripe_views.py`
+ - ✅ `backend/igny8_core/business/billing/views/paypal_views.py`
-4. **Add URL routes:**
+4. **✅ Add URL routes:**
```python
# backend/igny8_core/business/billing/urls.py
urlpatterns += [
@@ -1184,65 +1206,84 @@ def send_low_credits_warning(account, current_credits, threshold):
]
```
-5. **Add email templates:**
+5. **✅ Add email templates:**
```
backend/igny8_core/templates/emails/
+ ├── base.html
├── welcome.html
├── password_reset.html
- ├── payment_confirmed.html
+ ├── email_verification.html
+ ├── payment_confirmation.html
+ ├── payment_approved.html
+ ├── payment_rejected.html
+ ├── payment_failed.html
├── subscription_activated.html
- └── low_credits.html
+ ├── subscription_renewal.html
+ ├── low_credits.html
+ └── refund_notification.html
```
-### Phase 2: Stripe Configuration (1 day)
+### Phase 2: Stripe Configuration ⏳ PENDING USER CREDENTIALS
-1. Create products and prices in Stripe Dashboard
-2. Configure webhook endpoint
-3. Get API keys and add to IntegrationProvider via admin
-4. Test in sandbox mode
+1. ⏳ Create products and prices in Stripe Dashboard (User action needed)
+2. ⏳ Configure webhook endpoint (User action needed)
+3. ⏳ Get API keys and add to IntegrationProvider via admin (User action needed)
+4. ⏳ Test in sandbox mode (After credentials added)
-### Phase 3: Frontend Integration (1-2 days)
+### Phase 3: Frontend Integration ✅ COMPLETE
-1. Add billing API methods
-2. Update PlansAndBillingPage with payment buttons
-3. Update UsageAnalyticsPage with credit purchase
-4. Add success/cancel handling
+1. ✅ Add billing API methods (stripe, paypal, purchaseCredits, subscribeToPlan)
+2. ✅ Update PlansAndBillingPage with payment buttons
+3. ✅ Add PaymentGatewaySelector component
+4. ✅ Add success/cancel handling
-### Phase 4: Email Configuration (1 day)
+### Phase 4: Email Configuration ⏳ PENDING USER CREDENTIALS
-1. Add Resend API key to IntegrationProvider
-2. Verify domain
-3. Test all email triggers
+1. ⏳ Add Resend API key to IntegrationProvider (User action needed)
+2. ⏳ Verify domain at resend.com (User action needed)
+3. ⏳ Test all email triggers (After credentials added)
-### Phase 5: Testing (1-2 days)
+### Phase 5: Testing ⏳ PENDING USER CREDENTIALS
-1. Test Stripe checkout flow (sandbox)
-2. Test PayPal flow (sandbox)
-3. Test webhook handling
-4. Test email delivery
-5. Switch to production credentials
+1. ⏳ Test Stripe checkout flow (sandbox) - After credentials added
+2. ⏳ Test PayPal flow (sandbox) - After credentials added
+3. ⏳ Test webhook handling - After webhooks configured
+4. ⏳ Test email delivery - After Resend configured
+5. ⏳ Switch to production credentials - After testing complete
---
## Summary
-**Total Estimated Time:** 6-9 days
+**Implementation Status:** ✅ **CODE COMPLETE** - Ready for credentials and testing
-**Dependencies:**
-- Stripe account with products/prices created
-- PayPal developer account with app created
-- Resend account with domain verified
+**Time Spent:** 2-3 days (Backend + Frontend implementation)
-**Files to Create:**
-- `stripe_service.py`
-- `stripe_views.py`
-- `paypal_service.py`
-- `paypal_views.py`
-- Updated `email_service.py`
-- 5 email templates
+**Pending Actions (User):**
+- ⏳ Stripe account: Create products/prices, configure webhooks, add API keys
+- ⏳ PayPal developer account: Create app, configure webhooks, add credentials
+- ⏳ Resend account: Verify domain, add API key
+
+**Files Created:** ✅
+- ✅ `stripe_service.py` (500+ lines)
+- ✅ `stripe_views.py` (560+ lines)
+- ✅ `paypal_service.py` (500+ lines)
+- ✅ `paypal_views.py` (600+ lines)
+- ✅ Updated `email_service.py` (760+ lines)
+- ✅ 12 email templates
+- ✅ `PaymentGatewaySelector.tsx` (160+ lines)
+- ✅ Updated `PlansAndBillingPage.tsx`
+- ✅ Updated `billing.api.ts` (+285 lines)
**Existing Infrastructure Used:**
-- `IntegrationProvider` model for all credentials
-- `Payment`, `Invoice`, `CreditPackage` models
-- `PaymentService` for payment processing
-- `CreditService` for credit management
+- ✅ `IntegrationProvider` model for all credentials
+- ✅ `Payment`, `Invoice`, `CreditPackage` models
+- ✅ `PaymentService` for payment processing
+- ✅ `CreditService` for credit management
+
+**Next Steps:**
+1. Add Stripe credentials to Django Admin → Integration Providers
+2. Add PayPal credentials to Django Admin → Integration Providers
+3. Add Resend API key to Django Admin → Integration Providers
+4. Test payment flows in sandbox mode
+5. Switch to production credentials when ready
diff --git a/frontend/src/components/billing/PaymentGatewaySelector.tsx b/frontend/src/components/billing/PaymentGatewaySelector.tsx
new file mode 100644
index 00000000..3dbeebc5
--- /dev/null
+++ b/frontend/src/components/billing/PaymentGatewaySelector.tsx
@@ -0,0 +1,192 @@
+/**
+ * PaymentGatewaySelector Component
+ * Allows users to select between Stripe, PayPal, and Manual payment methods
+ */
+
+import React, { useEffect, useState } from 'react';
+import { CreditCard, Building2, Wallet, Loader2, Check } from 'lucide-react';
+import { getAvailablePaymentGateways, PaymentGateway } from '@/services/billing.api';
+
+interface PaymentGatewayOption {
+ id: PaymentGateway;
+ name: string;
+ description: string;
+ icon: React.ReactNode;
+ available: boolean;
+ recommended?: boolean;
+}
+
+interface PaymentGatewaySelectorProps {
+ selectedGateway: PaymentGateway | null;
+ onSelectGateway: (gateway: PaymentGateway) => void;
+ showManual?: boolean;
+ className?: string;
+}
+
+export function PaymentGatewaySelector({
+ selectedGateway,
+ onSelectGateway,
+ showManual = true,
+ className = '',
+}: PaymentGatewaySelectorProps) {
+ const [loading, setLoading] = useState(true);
+ const [gateways, setGateways] = useState([]);
+
+ useEffect(() => {
+ async function loadGateways() {
+ try {
+ const available = await getAvailablePaymentGateways();
+
+ const options: PaymentGatewayOption[] = [
+ {
+ id: 'stripe',
+ name: 'Credit/Debit Card',
+ description: 'Pay securely with your credit or debit card via Stripe',
+ icon: ,
+ available: available.stripe,
+ recommended: available.stripe,
+ },
+ {
+ id: 'paypal',
+ name: 'PayPal',
+ description: 'Pay with your PayPal account or PayPal Credit',
+ icon: (
+
+
+
+ ),
+ available: available.paypal,
+ },
+ ];
+
+ if (showManual) {
+ options.push({
+ id: 'manual',
+ name: 'Bank Transfer',
+ description: 'Pay via bank transfer with manual confirmation',
+ icon: ,
+ available: available.manual,
+ });
+ }
+
+ setGateways(options);
+
+ // Auto-select first available gateway if none selected
+ if (!selectedGateway) {
+ const firstAvailable = options.find((g) => g.available);
+ if (firstAvailable) {
+ onSelectGateway(firstAvailable.id);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load payment gateways:', error);
+ // Fallback to manual only
+ setGateways([
+ {
+ id: 'manual',
+ name: 'Bank Transfer',
+ description: 'Pay via bank transfer with manual confirmation',
+ icon: ,
+ available: true,
+ },
+ ]);
+ if (!selectedGateway) {
+ onSelectGateway('manual');
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ loadGateways();
+ }, [selectedGateway, onSelectGateway, showManual]);
+
+ if (loading) {
+ return (
+
+
+ Loading payment options...
+
+ );
+ }
+
+ const availableGateways = gateways.filter((g) => g.available);
+
+ if (availableGateways.length === 0) {
+ return (
+
+
+ No payment methods are currently available. Please contact support.
+
+
+ );
+ }
+
+ return (
+
+
+ Select Payment Method
+
+
+ {availableGateways.map((gateway) => (
+
onSelectGateway(gateway.id)}
+ className={`relative flex items-start rounded-lg border-2 p-4 text-left transition-all ${
+ selectedGateway === gateway.id
+ ? 'border-indigo-600 bg-indigo-50 ring-1 ring-indigo-600'
+ : 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
+ }`}
+ >
+
+ {gateway.icon}
+
+
+
+
+ {gateway.name}
+
+ {gateway.recommended && (
+
+ Recommended
+
+ )}
+
+
+ {gateway.description}
+
+
+ {selectedGateway === gateway.id && (
+
+
+
+ )}
+
+ ))}
+
+ {selectedGateway === 'manual' && (
+
+ After submitting, you'll receive bank details to complete the transfer.
+ Your account will be activated once we confirm the payment (usually within 1-2 business days).
+
+ )}
+
+ );
+}
+
+export default PaymentGatewaySelector;
diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx
index 221b6d8f..9cf81798 100644
--- a/frontend/src/pages/account/PlansAndBillingPage.tsx
+++ b/frontend/src/pages/account/PlansAndBillingPage.tsx
@@ -8,7 +8,6 @@ import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import {
CreditCardIcon,
- BoxIcon as PackageIcon,
TrendingUpIcon,
FileTextIcon,
WalletIcon,
@@ -56,6 +55,12 @@ import {
cancelSubscription,
type Plan,
type Subscription,
+ // Payment gateway methods
+ subscribeToPlan,
+ purchaseCredits,
+ openStripeBillingPortal,
+ getAvailablePaymentGateways,
+ type PaymentGateway,
} from '../../services/billing.api';
import { useAuthStore } from '../../store/authStore';
@@ -73,6 +78,7 @@ export default function PlansAndBillingPage() {
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [selectedBillingCycle, setSelectedBillingCycle] = useState<'monthly' | 'annual'>('monthly');
+ const [selectedGateway, setSelectedGateway] = useState('stripe');
// Data States
const [creditBalance, setCreditBalance] = useState(null);
@@ -83,11 +89,37 @@ export default function PlansAndBillingPage() {
const [plans, setPlans] = useState([]);
const [subscriptions, setSubscriptions] = useState([]);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(undefined);
+ const [availableGateways, setAvailableGateways] = useState<{ stripe: boolean; paypal: boolean; manual: boolean }>({
+ stripe: false,
+ paypal: false,
+ manual: true,
+ });
useEffect(() => {
if (hasLoaded.current) return;
hasLoaded.current = true;
loadData();
+
+ // Handle payment gateway return URLs
+ const params = new URLSearchParams(window.location.search);
+ const success = params.get('success');
+ const canceled = params.get('canceled');
+ const purchase = params.get('purchase');
+
+ if (success === 'true') {
+ toast?.success?.('Subscription activated successfully!');
+ // Clean up URL
+ window.history.replaceState({}, '', window.location.pathname);
+ } else if (canceled === 'true') {
+ toast?.info?.('Payment was cancelled');
+ window.history.replaceState({}, '', window.location.pathname);
+ } else if (purchase === 'success') {
+ toast?.success?.('Credits purchased successfully!');
+ window.history.replaceState({}, '', window.location.pathname);
+ } else if (purchase === 'canceled') {
+ toast?.info?.('Credit purchase was cancelled');
+ window.history.replaceState({}, '', window.location.pathname);
+ }
}, []);
const handleError = (err: any, fallback: string) => {
@@ -157,6 +189,23 @@ export default function PlansAndBillingPage() {
subs.push({ id: accountPlan.id || 0, plan: accountPlan, status: 'active' } as any);
}
setSubscriptions(subs);
+
+ // Load available payment gateways
+ try {
+ const gateways = await getAvailablePaymentGateways();
+ setAvailableGateways(gateways);
+ // Auto-select first available gateway
+ if (gateways.stripe) {
+ setSelectedGateway('stripe');
+ } else if (gateways.paypal) {
+ setSelectedGateway('paypal');
+ } else {
+ setSelectedGateway('manual');
+ }
+ } catch {
+ // Non-critical - just keep defaults
+ console.log('Could not load payment gateways, using defaults');
+ }
} catch (err: any) {
if (err?.status === 429 && allowRetry) {
setError('Request was throttled. Retrying...');
@@ -172,6 +221,15 @@ export default function PlansAndBillingPage() {
const handleSelectPlan = async (planId: number) => {
try {
setPlanLoadingId(planId);
+
+ // Use payment gateway integration for Stripe/PayPal
+ if (selectedGateway === 'stripe' || selectedGateway === 'paypal') {
+ const { redirect_url } = await subscribeToPlan(planId.toString(), selectedGateway);
+ window.location.href = redirect_url;
+ return;
+ }
+
+ // Fall back to manual/bank transfer flow
await createSubscription({ plan_id: planId, payment_method: selectedPaymentMethod });
toast?.success?.('Plan upgraded successfully!');
setShowUpgradeModal(false);
@@ -201,7 +259,16 @@ export default function PlansAndBillingPage() {
const handlePurchaseCredits = async (packageId: number) => {
try {
setPurchaseLoadingId(packageId);
- await purchaseCreditPackage({ package_id: packageId, payment_method: selectedPaymentMethod as any || 'stripe' });
+
+ // Use payment gateway integration for Stripe/PayPal
+ if (selectedGateway === 'stripe' || selectedGateway === 'paypal') {
+ const { redirect_url } = await purchaseCredits(packageId.toString(), selectedGateway);
+ window.location.href = redirect_url;
+ return;
+ }
+
+ // Fall back to manual/bank transfer flow
+ await purchaseCreditPackage({ package_id: packageId, payment_method: selectedPaymentMethod as any || 'manual' });
toast?.success?.('Credits purchased successfully!');
await loadData();
} catch (err: any) {
@@ -211,6 +278,15 @@ export default function PlansAndBillingPage() {
}
};
+ const handleManageSubscription = async () => {
+ try {
+ const { portal_url } = await openStripeBillingPortal();
+ window.location.href = portal_url;
+ } catch (err: any) {
+ handleError(err, 'Failed to open billing portal');
+ }
+ };
+
const handleDownloadInvoice = async (invoiceId: number) => {
try {
const blob = await downloadInvoicePDF(invoiceId);
@@ -312,14 +388,26 @@ export default function PlansAndBillingPage() {
{currentPlan?.description || 'Select a plan to unlock features'}
- setShowUpgradeModal(true)}
- startIcon={ }
- >
- Upgrade
-
+
+ {availableGateways.stripe && hasActivePlan && (
+
}
+ >
+ Manage Billing
+
+ )}
+
setShowUpgradeModal(true)}
+ startIcon={ }
+ >
+ Upgrade
+
+
{/* Quick Stats */}
@@ -467,6 +555,37 @@ export default function PlansAndBillingPage() {
Top up your credit balance
+ {/* Compact Payment Gateway Selector for Credits */}
+ {(availableGateways.stripe || availableGateways.paypal) && (
+
+ {availableGateways.stripe && (
+ setSelectedGateway('stripe')}
+ className={`p-1.5 rounded-md transition-colors ${
+ selectedGateway === 'stripe'
+ ? 'bg-white dark:bg-gray-700 shadow-sm'
+ : 'hover:bg-white/50 dark:hover:bg-gray-700/50'
+ }`}
+ title="Pay with Card"
+ >
+
+
+ )}
+ {availableGateways.paypal && (
+ setSelectedGateway('paypal')}
+ className={`p-1.5 rounded-md transition-colors ${
+ selectedGateway === 'paypal'
+ ? 'bg-white dark:bg-gray-700 shadow-sm'
+ : 'hover:bg-white/50 dark:hover:bg-gray-700/50'
+ }`}
+ title="Pay with PayPal"
+ >
+
+
+ )}
+
+ )}
{packages.slice(0, 4).map((pkg) => (
@@ -701,8 +820,9 @@ export default function PlansAndBillingPage() {
- {/* Billing Toggle */}
-
+ {/* Billing Toggle & Payment Gateway */}
+
+ {/* Billing Cycle Toggle */}
setSelectedBillingCycle('monthly')}
@@ -726,6 +846,51 @@ export default function PlansAndBillingPage() {
Save 20%
+
+ {/* Payment Gateway Selector */}
+ {(availableGateways.stripe || availableGateways.paypal) && (
+
+ {availableGateways.stripe && (
+ setSelectedGateway('stripe')}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors flex items-center gap-2 ${
+ selectedGateway === 'stripe'
+ ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
+ }`}
+ >
+
+ Card
+
+ )}
+ {availableGateways.paypal && (
+ setSelectedGateway('paypal')}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors flex items-center gap-2 ${
+ selectedGateway === 'paypal'
+ ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
+ }`}
+ >
+
+ PayPal
+
+ )}
+ {availableGateways.manual && (
+ setSelectedGateway('manual')}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors flex items-center gap-2 ${
+ selectedGateway === 'manual'
+ ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
+ }`}
+ >
+
+ Bank
+
+ )}
+
+ )}
{/* Plans Grid */}
diff --git a/frontend/src/services/billing.api.ts b/frontend/src/services/billing.api.ts
index e0e44dae..5c4a00a5 100644
--- a/frontend/src/services/billing.api.ts
+++ b/frontend/src/services/billing.api.ts
@@ -1126,3 +1126,289 @@ export async function getDashboardStats(params?: { site_id?: number; days?: numb
const query = searchParams.toString();
return fetchAPI(`/v1/account/dashboard/stats/${query ? `?${query}` : ''}`);
}
+
+// ============================================================================
+// STRIPE INTEGRATION
+// ============================================================================
+
+export interface StripeConfig {
+ publishable_key: string;
+ is_sandbox: boolean;
+}
+
+export interface StripeCheckoutSession {
+ checkout_url: string;
+ session_id: string;
+}
+
+export interface StripeBillingPortalSession {
+ portal_url: string;
+}
+
+/**
+ * Get Stripe publishable key for frontend initialization
+ */
+export async function getStripeConfig(): Promise
{
+ return fetchAPI('/v1/billing/stripe/config/');
+}
+
+/**
+ * Create Stripe Checkout session for plan subscription
+ * Redirects user to Stripe's hosted checkout page
+ */
+export async function createStripeCheckout(planId: string, options?: {
+ success_url?: string;
+ cancel_url?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/stripe/checkout/', {
+ method: 'POST',
+ body: JSON.stringify({
+ plan_id: planId,
+ ...options,
+ }),
+ });
+}
+
+/**
+ * Create Stripe Checkout session for credit package purchase
+ * Redirects user to Stripe's hosted checkout page
+ */
+export async function createStripeCreditCheckout(packageId: string, options?: {
+ success_url?: string;
+ cancel_url?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/stripe/credit-checkout/', {
+ method: 'POST',
+ body: JSON.stringify({
+ package_id: packageId,
+ ...options,
+ }),
+ });
+}
+
+/**
+ * Create Stripe Billing Portal session for subscription management
+ * Allows customers to manage payment methods, view invoices, cancel subscription
+ */
+export async function openStripeBillingPortal(options?: {
+ return_url?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/stripe/billing-portal/', {
+ method: 'POST',
+ body: JSON.stringify(options || {}),
+ });
+}
+
+// ============================================================================
+// PAYPAL INTEGRATION
+// ============================================================================
+
+export interface PayPalConfig {
+ client_id: string;
+ is_sandbox: boolean;
+ currency: string;
+}
+
+export interface PayPalOrder {
+ order_id: string;
+ status: string;
+ approval_url: string;
+ links?: Array<{ rel: string; href: string }>;
+ credit_package_id?: string;
+ credit_amount?: number;
+ plan_id?: string;
+ plan_name?: string;
+}
+
+export interface PayPalCaptureResult {
+ order_id: string;
+ status: string;
+ capture_id: string;
+ amount: string;
+ currency: string;
+ credits_added?: number;
+ new_balance?: number;
+ subscription_id?: string;
+ plan_name?: string;
+ payment_id?: number;
+}
+
+export interface PayPalSubscription {
+ subscription_id: string;
+ status: string;
+ approval_url: string;
+ links?: Array<{ rel: string; href: string }>;
+}
+
+/**
+ * Get PayPal client ID for frontend initialization
+ */
+export async function getPayPalConfig(): Promise {
+ return fetchAPI('/v1/billing/paypal/config/');
+}
+
+/**
+ * Create PayPal order for credit package purchase
+ * Returns approval URL for PayPal redirect
+ */
+export async function createPayPalCreditOrder(packageId: string, options?: {
+ return_url?: string;
+ cancel_url?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/paypal/create-order/', {
+ method: 'POST',
+ body: JSON.stringify({
+ package_id: packageId,
+ ...options,
+ }),
+ });
+}
+
+/**
+ * Create PayPal order for plan subscription (one-time payment model)
+ * Returns approval URL for PayPal redirect
+ */
+export async function createPayPalSubscriptionOrder(planId: string, options?: {
+ return_url?: string;
+ cancel_url?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/paypal/create-subscription-order/', {
+ method: 'POST',
+ body: JSON.stringify({
+ plan_id: planId,
+ ...options,
+ }),
+ });
+}
+
+/**
+ * Capture PayPal order after user approval
+ * Call this when user returns from PayPal with approved order
+ */
+export async function capturePayPalOrder(orderId: string, options?: {
+ package_id?: string;
+ plan_id?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/paypal/capture-order/', {
+ method: 'POST',
+ body: JSON.stringify({
+ order_id: orderId,
+ ...options,
+ }),
+ });
+}
+
+/**
+ * Create PayPal recurring subscription
+ * Requires plan to have paypal_plan_id configured
+ */
+export async function createPayPalSubscription(planId: string, options?: {
+ return_url?: string;
+ cancel_url?: string;
+}): Promise {
+ return fetchAPI('/v1/billing/paypal/create-subscription/', {
+ method: 'POST',
+ body: JSON.stringify({
+ plan_id: planId,
+ ...options,
+ }),
+ });
+}
+
+// ============================================================================
+// PAYMENT GATEWAY HELPERS
+// ============================================================================
+
+export type PaymentGateway = 'stripe' | 'paypal' | 'manual';
+
+/**
+ * Helper to check if Stripe is configured
+ */
+export async function isStripeConfigured(): Promise {
+ try {
+ const config = await getStripeConfig();
+ return !!config.publishable_key;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Helper to check if PayPal is configured
+ */
+export async function isPayPalConfigured(): Promise {
+ try {
+ const config = await getPayPalConfig();
+ return !!config.client_id;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get available payment gateways
+ */
+export async function getAvailablePaymentGateways(): Promise<{
+ stripe: boolean;
+ paypal: boolean;
+ manual: boolean;
+}> {
+ const [stripeAvailable, paypalAvailable] = await Promise.all([
+ isStripeConfigured(),
+ isPayPalConfigured(),
+ ]);
+
+ return {
+ stripe: stripeAvailable,
+ paypal: paypalAvailable,
+ manual: true, // Manual payment is always available
+ };
+}
+
+/**
+ * Subscribe to plan using preferred payment gateway
+ */
+export async function subscribeToPlan(
+ planId: string,
+ gateway: PaymentGateway,
+ options?: { return_url?: string; cancel_url?: string }
+): Promise<{ redirect_url: string }> {
+ switch (gateway) {
+ case 'stripe': {
+ const session = await createStripeCheckout(planId, options);
+ return { redirect_url: session.checkout_url };
+ }
+ case 'paypal': {
+ const order = await createPayPalSubscriptionOrder(planId, options);
+ return { redirect_url: order.approval_url };
+ }
+ case 'manual':
+ throw new Error('Manual payment requires different flow - use submitManualPayment()');
+ default:
+ throw new Error(`Unsupported payment gateway: ${gateway}`);
+ }
+}
+
+/**
+ * Purchase credit package using preferred payment gateway
+ */
+export async function purchaseCredits(
+ packageId: string,
+ gateway: PaymentGateway,
+ options?: { return_url?: string; cancel_url?: string }
+): Promise<{ redirect_url: string }> {
+ switch (gateway) {
+ case 'stripe': {
+ const session = await createStripeCreditCheckout(packageId, options);
+ return { redirect_url: session.checkout_url };
+ }
+ case 'paypal': {
+ const order = await createPayPalCreditOrder(packageId, options);
+ return { redirect_url: order.approval_url };
+ }
+ case 'manual':
+ throw new Error('Manual payment requires different flow - use submitManualPayment()');
+ default:
+ throw new Error(`Unsupported payment gateway: ${gateway}`);
+ }
+}