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 @@ + + +
+ + +Hi {{ user_name }},
+ +Thanks for signing up! Please verify your email address by clicking the button below:
+ ++ Verify Email +
+ +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
+
Hi {{ account_name }},
+ +| 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
+
Hi {{ user_name }},
+ +We received a request to reset your password. Click the button below to create a new password:
+ ++ Reset Password +
+ +If the button doesn't work, copy and paste this link into your browser:
+{{ reset_url }}
+ +
+Best regards,
+The IGNY8 Team
+
Hi {{ account_name }},
+ +| Invoice | +#{{ invoice_number }} | +
| Amount | +{{ currency }} {{ amount }} | +
| Plan | +{{ plan_name }} | +
| 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
+
Hi {{ account_name }},
+ +We have received your payment confirmation and it is now being reviewed by our team.
+ +| Invoice | +#{{ invoice_number }} | +
| Amount | +{{ currency }} {{ amount }} | +
| Payment Method | +{{ payment_method }} | +
| Reference | +{{ manual_reference }} | +
| 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
+
Hi {{ account_name }},
+ +| Plan | +{{ plan_name }} | +
| Reason | +{{ failure_reason }} | +
Please update your payment method to continue your subscription and avoid service interruption.
+ + + +If you need assistance, please contact our support team by replying to this email.
+ +
+Best regards,
+The IGNY8 Team
+
Hi {{ account_name }},
+ +| Invoice | +#{{ invoice_number }} | +
| Amount | +{{ currency }} {{ amount }} | +
| Reference | +{{ manual_reference }} | +
| 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
+
Hi {{ user_name }},
+ +| Invoice | +#{{ invoice_number }} | +
| Original Amount | +{{ currency }} {{ original_amount }} | +
| Refund Amount | +{{ currency }} {{ refund_amount }} | +
| Reason | +{{ reason }} | +
| Processed | +{{ refunded_at|date:"F j, Y H:i" }} | +
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
+
Hi {{ account_name }},
+ +| 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
+
Hi {{ account_name }},
+ +Your subscription will be renewed in {{ days_until_renewal }} days.
+ +| 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:
+ + + +
+Best regards,
+The IGNY8 Team
+
Hi {{ user_name }},
+ +We're excited to have you on board! Your account {{ account_name }} is ready to go.
+ ++ 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
+
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 "