FInal bank, stripe and paypal sandbox completed

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-08 00:12:41 +00:00
parent ad75fa031e
commit 7ad1f6bdff
19 changed files with 2622 additions and 375 deletions

View File

@@ -466,15 +466,22 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
def bulk_hard_delete(self, request, queryset):
"""PERMANENTLY delete selected accounts and ALL related data - cannot be undone!"""
import traceback
count = 0
errors = []
for account in queryset:
if account.slug != 'aws-admin': # Protect admin account
try:
account.hard_delete_with_cascade() # Permanently delete everything
count += 1
except Exception as e:
errors.append(f'{account.name}: {str(e)}')
if account.slug == 'aws-admin': # Protect admin account
errors.append(f'{account.name}: Protected system account')
continue
try:
account.hard_delete_with_cascade() # Permanently delete everything
count += 1
except Exception as e:
# Log full traceback for debugging
import logging
logger = logging.getLogger(__name__)
logger.error(f'Hard delete failed for account {account.pk} ({account.name}): {traceback.format_exc()}')
errors.append(f'{account.name}: {str(e)}')
if count > 0:
self.message_user(request, f'{count} account(s) and ALL related data permanently deleted.', messages.SUCCESS)
@@ -1000,7 +1007,7 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
list_filter = ['role', 'account', 'is_active', 'is_staff']
search_fields = ['email', 'username']
readonly_fields = ['created_at', 'updated_at']
readonly_fields = ['created_at', 'updated_at', 'password_display']
fieldsets = BaseUserAdmin.fieldsets + (
('IGNY8 Info', {'fields': ('account', 'role')}),
@@ -1018,8 +1025,45 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
'bulk_activate',
'bulk_deactivate',
'bulk_send_password_reset',
'bulk_set_temporary_password',
]
def password_display(self, obj):
"""Show password hash with copy button (for debugging only)"""
if obj.password:
return f'Hash: {obj.password[:50]}...'
return 'No password set'
password_display.short_description = 'Password Hash'
def bulk_set_temporary_password(self, request, queryset):
"""Set a temporary password for selected users and display it"""
import secrets
import string
# Generate a secure random password
alphabet = string.ascii_letters + string.digits
temp_password = ''.join(secrets.choice(alphabet) for _ in range(12))
users_updated = []
for user in queryset:
user.set_password(temp_password)
user.save(update_fields=['password'])
users_updated.append(user.email)
if users_updated:
# Display the password in the message (only visible to admin)
self.message_user(
request,
f'Temporary password set for {len(users_updated)} user(s): "{temp_password}" (same password for all selected users)',
messages.SUCCESS
)
self.message_user(
request,
f'Users updated: {", ".join(users_updated)}',
messages.INFO
)
bulk_set_temporary_password.short_description = '🔑 Set temporary password (will display)'
def get_queryset(self, request):
"""Filter users by account for non-superusers"""
qs = super().get_queryset(request)

View File

@@ -227,6 +227,8 @@ class Account(SoftDeletableModel):
# Core (last due to dependencies)
'sector_set',
'site_set',
# Users (delete after sites to avoid FK issues, owner is SET_NULL)
'users',
# Subscription (OneToOne)
'subscription',
]
@@ -285,6 +287,12 @@ class Account(SoftDeletableModel):
from django.core.exceptions import PermissionDenied
raise PermissionDenied("System account cannot be deleted.")
# Clear owner reference first to avoid FK constraint issues
# (owner is SET_NULL but we're deleting the user who is the owner)
if self.owner:
self.owner = None
self.save(update_fields=['owner'])
# Cascade hard-delete all related objects first
self._cascade_delete_related(hard_delete=True)

View File

@@ -53,7 +53,7 @@ class AccountSerializer(serializers.ModelSerializer):
fields = [
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
'credits', 'status', 'payment_method',
'subscription', 'created_at'
'subscription', 'billing_country', 'created_at'
]
read_only_fields = ['owner', 'created_at']

View File

@@ -192,19 +192,32 @@ class BillingViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['get'], url_path='payment-methods', permission_classes=[AllowAny])
def list_payment_methods(self, request):
"""
Get available payment methods (global only).
Get available payment methods filtered by country code.
Public endpoint - only returns enabled payment methods.
Does not expose sensitive configuration details.
Note: Country-specific filtering has been removed per Phase 1.1.2.
The country_code field is retained for future use but currently ignored.
All enabled payment methods are returned regardless of country_code value.
Query Parameters:
- country_code: ISO 2-letter country code (e.g., 'US', 'PK')
Returns methods for:
1. Specified country (country_code=XX)
2. Global methods (country_code='*')
"""
# Return all enabled payment methods (global approach - no country filtering)
# Country-specific filtering removed per Task 1.1.2 of Master Implementation Plan
methods = PaymentMethodConfig.objects.filter(
is_enabled=True
).order_by('sort_order')
country_code = request.query_params.get('country_code', '').upper()
if country_code:
# Filter by specific country OR global methods
methods = PaymentMethodConfig.objects.filter(
is_enabled=True
).filter(
Q(country_code=country_code) | Q(country_code='*')
).order_by('sort_order')
else:
# No country specified - return only global methods
methods = PaymentMethodConfig.objects.filter(
is_enabled=True,
country_code='*'
).order_by('sort_order')
# Serialize using the proper serializer
serializer = PaymentMethodConfigSerializer(methods, many=True)
@@ -686,14 +699,38 @@ class InvoiceViewSet(AccountModelViewSet):
def download_pdf(self, request, pk=None):
"""Download invoice PDF"""
try:
invoice = self.get_queryset().get(pk=pk)
invoice = self.get_queryset().select_related(
'account', 'account__owner', 'subscription', 'subscription__plan'
).get(pk=pk)
pdf_bytes = InvoiceService.generate_pdf(invoice)
# Build descriptive filename
plan_name = ''
if invoice.subscription and invoice.subscription.plan:
plan_name = invoice.subscription.plan.name.replace(' ', '-')
elif invoice.metadata and 'plan_name' in invoice.metadata:
plan_name = invoice.metadata.get('plan_name', '').replace(' ', '-')
date_str = invoice.invoice_date.strftime('%Y-%m-%d') if invoice.invoice_date else ''
filename_parts = ['IGNY8', 'Invoice', invoice.invoice_number]
if plan_name:
filename_parts.append(plan_name)
if date_str:
filename_parts.append(date_str)
filename = '-'.join(filename_parts) + '.pdf'
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"'
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
except Invoice.DoesNotExist:
return error_response(error='Invoice not found', status_code=404, request=request)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'PDF generation failed for invoice {pk}: {str(e)}', exc_info=True)
return error_response(error=f'Failed to generate PDF: {str(e)}', status_code=500, request=request)
class PaymentViewSet(AccountModelViewSet):
@@ -768,6 +805,7 @@ class PaymentViewSet(AccountModelViewSet):
payment_method = request.data.get('payment_method', 'bank_transfer')
reference = request.data.get('reference', '')
notes = request.data.get('notes', '')
currency = request.data.get('currency', 'USD')
if not amount:
return error_response(error='Amount is required', status_code=400, request=request)
@@ -777,12 +815,15 @@ class PaymentViewSet(AccountModelViewSet):
invoice = None
if invoice_id:
invoice = Invoice.objects.get(id=invoice_id, account=account)
# Use invoice currency if not explicitly provided
if not request.data.get('currency') and invoice:
currency = invoice.currency
payment = Payment.objects.create(
account=account,
invoice=invoice,
amount=amount,
currency='USD',
currency=currency,
payment_method=payment_method,
status='pending_approval',
manual_reference=reference,

View File

@@ -398,6 +398,20 @@ class Invoice(AccountBaseModel):
def tax_amount(self):
return self.tax
@property
def tax_rate(self):
"""Get tax rate from metadata if stored"""
if self.metadata and 'tax_rate' in self.metadata:
return self.metadata['tax_rate']
return 0
@property
def discount_amount(self):
"""Get discount amount from metadata if stored"""
if self.metadata and 'discount_amount' in self.metadata:
return self.metadata['discount_amount']
return 0
@property
def total_amount(self):
return self.total

View File

@@ -52,28 +52,27 @@ class InvoiceService:
def generate_invoice_number(account: Account) -> str:
"""
Generate unique invoice number with atomic locking to prevent duplicates
Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
Format: INV-{YY}{MM}{COUNTER} (e.g., INV-26010001)
"""
from django.db import transaction
now = timezone.now()
prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
prefix = f"INV-{now.year % 100:02d}{now.month:02d}"
# Use atomic transaction with SELECT FOR UPDATE to prevent race conditions
with transaction.atomic():
# Lock the invoice table for this account/month to get accurate count
# Lock the invoice table for this month to get accurate count
count = Invoice.objects.select_for_update().filter(
account=account,
created_at__year=now.year,
created_at__month=now.month
).count()
invoice_number = f"{prefix}-{count + 1:04d}"
invoice_number = f"{prefix}{count + 1:04d}"
# Double-check uniqueness (should not happen with lock, but safety check)
while Invoice.objects.filter(invoice_number=invoice_number).exists():
count += 1
invoice_number = f"{prefix}-{count + 1:04d}"
invoice_number = f"{prefix}{count + 1:04d}"
return invoice_number
@@ -87,9 +86,10 @@ class InvoiceService:
"""
Create invoice for subscription billing period
Currency logic:
- USD for online payments (stripe, paypal)
- Local currency (PKR) only for bank_transfer in applicable countries
SIMPLIFIED CURRENCY LOGIC:
- ALL invoices are in USD (consistent for accounting)
- PKR equivalent is calculated and stored in metadata for display purposes
- Bank transfer users see PKR equivalent but invoice is technically USD
"""
account = subscription.account
plan = subscription.plan
@@ -112,22 +112,15 @@ class InvoiceService:
invoice_date = timezone.now().date()
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
# Determine currency based on payment method:
# - Online payments (stripe, paypal): Always USD
# - Manual payments (bank_transfer, local_wallet): Local currency for applicable countries
# ALWAYS use USD for invoices (simplified accounting)
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
payment_method = account.payment_method
online_payment_methods = ['stripe', 'paypal']
currency = 'USD'
usd_price = float(plan.price)
if payment_method in online_payment_methods:
# Online payments are always in USD
currency = 'USD'
local_price = float(plan.price)
else:
# Manual payments use local currency for applicable countries
currency = get_currency_for_country(account.billing_country)
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
# Calculate local equivalent for display purposes (if applicable)
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD'
local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price
invoice = Invoice.objects.create(
account=account,
@@ -143,17 +136,19 @@ class InvoiceService:
'billing_period_end': billing_period_end.isoformat(),
'subscription_id': subscription.id, # Keep in metadata for backward compatibility
'usd_price': str(plan.price), # Store original USD price
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0),
'payment_method': payment_method
'local_currency': local_currency, # Store local currency code for display
'local_equivalent': str(round(local_equivalent, 2)), # Store local equivalent for display
'exchange_rate': str(local_equivalent / usd_price if usd_price > 0 else 1.0),
'payment_method': account.payment_method
}
)
# Add line item for subscription with converted price
# Add line item for subscription in USD
invoice.add_line_item(
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
quantity=1,
unit_price=Decimal(str(local_price)),
amount=Decimal(str(local_price))
unit_price=Decimal(str(usd_price)),
amount=Decimal(str(usd_price))
)
invoice.calculate_totals()
@@ -170,27 +165,22 @@ class InvoiceService:
"""
Create invoice for credit package purchase
Currency logic:
- USD for online payments (stripe, paypal)
- Local currency (PKR) only for bank_transfer in applicable countries
SIMPLIFIED CURRENCY LOGIC:
- ALL invoices are in USD (consistent for accounting)
- PKR equivalent is calculated and stored in metadata for display purposes
"""
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
invoice_date = timezone.now().date()
# Determine currency based on payment method
# ALWAYS use USD for invoices (simplified accounting)
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
payment_method = account.payment_method
online_payment_methods = ['stripe', 'paypal']
currency = 'USD'
usd_price = float(credit_package.price)
if payment_method in online_payment_methods:
# Online payments are always in USD
currency = 'USD'
local_price = float(credit_package.price)
else:
# Manual payments use local currency for applicable countries
currency = get_currency_for_country(account.billing_country)
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
# Calculate local equivalent for display purposes (if applicable)
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD'
local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price
invoice = Invoice.objects.create(
account=account,
@@ -204,17 +194,19 @@ class InvoiceService:
'credit_package_id': credit_package.id,
'credit_amount': credit_package.credits,
'usd_price': str(credit_package.price), # Store original USD price
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0),
'payment_method': payment_method
'local_currency': local_currency, # Store local currency code for display
'local_equivalent': str(round(local_equivalent, 2)), # Store local equivalent for display
'exchange_rate': str(local_equivalent / usd_price if usd_price > 0 else 1.0),
'payment_method': account.payment_method
},
)
# Add line item for credit package with converted price
# Add line item for credit package in USD
invoice.add_line_item(
description=f"{credit_package.name} - {credit_package.credits:,} Credits",
quantity=1,
unit_price=Decimal(str(local_price)),
amount=Decimal(str(local_price))
unit_price=Decimal(str(usd_price)),
amount=Decimal(str(usd_price))
)
invoice.calculate_totals()
@@ -312,43 +304,13 @@ class InvoiceService:
@staticmethod
def generate_pdf(invoice: Invoice) -> bytes:
"""
Generate PDF for invoice
TODO: Implement PDF generation using reportlab or weasyprint
For now, return placeholder
Generate professional PDF invoice using ReportLab
"""
from io import BytesIO
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
# Placeholder - implement PDF generation
buffer = BytesIO()
# Simple text representation for now
content = f"""
INVOICE #{invoice.invoice_number}
Bill To: {invoice.account.name}
Email: {invoice.billing_email}
Date: {invoice.created_at.strftime('%Y-%m-%d')}
Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'}
Line Items:
"""
for item in invoice.line_items:
content += f" {item['description']} - ${item['amount']}\n"
content += f"""
Subtotal: ${invoice.subtotal}
Tax: ${invoice.tax_amount}
Total: ${invoice.total_amount}
Status: {invoice.status.upper()}
"""
buffer.write(content.encode('utf-8'))
buffer.seek(0)
return buffer.getvalue()
# Use the professional PDF generator
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
return pdf_buffer.getvalue()
@staticmethod
def get_account_invoices(

View File

@@ -9,17 +9,32 @@ from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, HRFlowable
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
from django.conf import settings
import os
import logging
logger = logging.getLogger(__name__)
# Logo path - check multiple possible locations
LOGO_PATHS = [
'/data/app/igny8/frontend/public/images/logo/IGNY8_LIGHT_LOGO.png',
'/app/static/images/logo/IGNY8_LIGHT_LOGO.png',
]
class InvoicePDFGenerator:
"""Generate PDF invoices"""
@staticmethod
def get_logo_path():
"""Find the logo file from possible locations"""
for path in LOGO_PATHS:
if os.path.exists(path):
return path
return None
@staticmethod
def generate_invoice_pdf(invoice):
"""
@@ -39,8 +54,8 @@ class InvoicePDFGenerator:
pagesize=letter,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=0.75*inch,
bottomMargin=0.75*inch
topMargin=0.5*inch,
bottomMargin=0.5*inch
)
# Container for PDF elements
@@ -51,17 +66,19 @@ class InvoicePDFGenerator:
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
fontSize=28,
textColor=colors.HexColor('#1f2937'),
spaceAfter=30,
spaceAfter=0,
fontName='Helvetica-Bold',
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=14,
textColor=colors.HexColor('#374151'),
spaceAfter=12,
fontSize=12,
textColor=colors.HexColor('#1f2937'),
spaceAfter=8,
fontName='Helvetica-Bold',
)
normal_style = ParagraphStyle(
@@ -69,145 +86,292 @@ class InvoicePDFGenerator:
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#4b5563'),
fontName='Helvetica',
)
# Header
elements.append(Paragraph('INVOICE', title_style))
elements.append(Spacer(1, 0.2*inch))
label_style = ParagraphStyle(
'LabelStyle',
parent=styles['Normal'],
fontSize=9,
textColor=colors.HexColor('#6b7280'),
fontName='Helvetica',
)
# Company info and invoice details side by side
company_data = [
['<b>From:</b>', f'<b>Invoice #:</b> {invoice.invoice_number}'],
[getattr(settings, 'COMPANY_NAME', 'Igny8'), f'<b>Date:</b> {invoice.created_at.strftime("%B %d, %Y")}'],
[getattr(settings, 'COMPANY_ADDRESS', ''), f'<b>Due Date:</b> {invoice.due_date.strftime("%B %d, %Y")}'],
[getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL), f'<b>Status:</b> {invoice.status.upper()}'],
]
value_style = ParagraphStyle(
'ValueStyle',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#1f2937'),
fontName='Helvetica-Bold',
)
company_table = Table(company_data, colWidths=[3.5*inch, 3*inch])
company_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#4b5563')),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
right_align_style = ParagraphStyle(
'RightAlign',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#4b5563'),
alignment=TA_RIGHT,
fontName='Helvetica',
)
right_bold_style = ParagraphStyle(
'RightBold',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#1f2937'),
alignment=TA_RIGHT,
fontName='Helvetica-Bold',
)
# Header with Logo and Invoice title
logo_path = InvoicePDFGenerator.get_logo_path()
header_data = []
if logo_path:
try:
logo = Image(logo_path, width=1.5*inch, height=0.5*inch)
logo.hAlign = 'LEFT'
header_data = [[logo, Paragraph('INVOICE', title_style)]]
except Exception as e:
logger.warning(f"Could not load logo: {e}")
header_data = [[Paragraph('IGNY8', title_style), Paragraph('INVOICE', title_style)]]
else:
header_data = [[Paragraph('IGNY8', title_style), Paragraph('INVOICE', title_style)]]
header_table = Table(header_data, colWidths=[3.5*inch, 3*inch])
header_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
]))
elements.append(company_table)
elements.append(header_table)
elements.append(Spacer(1, 0.3*inch))
# Bill to section
elements.append(Paragraph('<b>Bill To:</b>', heading_style))
bill_to_data = [
[invoice.account.name],
[invoice.account.owner.email],
# Divider line
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceAfter=20))
# Invoice details section (right side info)
invoice_info = [
[Paragraph('Invoice Number:', label_style), Paragraph(invoice.invoice_number, value_style)],
[Paragraph('Date:', label_style), Paragraph(invoice.created_at.strftime("%B %d, %Y"), value_style)],
[Paragraph('Due Date:', label_style), Paragraph(invoice.due_date.strftime("%B %d, %Y"), value_style)],
[Paragraph('Status:', label_style), Paragraph(invoice.status.upper(), value_style)],
]
if hasattr(invoice.account, 'billing_email') and invoice.account.billing_email:
bill_to_data.append([f'Billing: {invoice.account.billing_email}'])
invoice_info_table = Table(invoice_info, colWidths=[1.2*inch, 2*inch])
invoice_info_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
for line in bill_to_data:
elements.append(Paragraph(line[0], normal_style))
# From and To section
company_name = getattr(settings, 'COMPANY_NAME', 'Igny8')
company_email = getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL)
elements.append(Spacer(1, 0.3*inch))
from_section = [
Paragraph('FROM', heading_style),
Paragraph(company_name, value_style),
Paragraph(company_email, normal_style),
]
customer_name = invoice.account.name if invoice.account else 'N/A'
customer_email = invoice.account.owner.email if invoice.account and invoice.account.owner else invoice.account.billing_email if invoice.account else 'N/A'
billing_email = invoice.account.billing_email if invoice.account and hasattr(invoice.account, 'billing_email') and invoice.account.billing_email else None
to_section = [
Paragraph('BILL TO', heading_style),
Paragraph(customer_name, value_style),
Paragraph(customer_email, normal_style),
]
if billing_email and billing_email != customer_email:
to_section.append(Paragraph(f'Billing: {billing_email}', normal_style))
# Create from/to layout
from_content = []
for item in from_section:
from_content.append([item])
from_table = Table(from_content, colWidths=[3*inch])
to_content = []
for item in to_section:
to_content.append([item])
to_table = Table(to_content, colWidths=[3*inch])
# Main info layout with From, To, and Invoice details
main_info = [[from_table, to_table, invoice_info_table]]
main_info_table = Table(main_info, colWidths=[2.3*inch, 2.3*inch, 2.4*inch])
main_info_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]))
elements.append(main_info_table)
elements.append(Spacer(1, 0.4*inch))
# Line items table
elements.append(Paragraph('<b>Items:</b>', heading_style))
elements.append(Paragraph('ITEMS', heading_style))
elements.append(Spacer(1, 0.1*inch))
# Table header
# Table header - use Paragraph for proper rendering
line_items_data = [
['Description', 'Quantity', 'Unit Price', 'Amount']
[
Paragraph('Description', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'))),
Paragraph('Qty', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_CENTER)),
Paragraph('Unit Price', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_RIGHT)),
Paragraph('Amount', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_RIGHT)),
]
]
# Get line items
for item in invoice.line_items.all():
# Get line items - line_items is a JSON field (list of dicts)
items = invoice.line_items or []
for item in items:
unit_price = float(item.get('unit_price', 0))
amount = float(item.get('amount', 0))
line_items_data.append([
item.description,
str(item.quantity),
f'{invoice.currency} {item.unit_price:.2f}',
f'{invoice.currency} {item.total_price:.2f}'
Paragraph(item.get('description', ''), normal_style),
Paragraph(str(item.get('quantity', 1)), ParagraphStyle('Center', parent=normal_style, alignment=TA_CENTER)),
Paragraph(f'{invoice.currency} {unit_price:.2f}', right_align_style),
Paragraph(f'{invoice.currency} {amount:.2f}', right_align_style),
])
# Add subtotal, tax, total rows
line_items_data.append(['', '', '<b>Subtotal:</b>', f'<b>{invoice.currency} {invoice.subtotal:.2f}</b>'])
if invoice.tax_amount and invoice.tax_amount > 0:
line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f'{invoice.currency} {invoice.tax_amount:.2f}'])
if invoice.discount_amount and invoice.discount_amount > 0:
line_items_data.append(['', '', 'Discount:', f'-{invoice.currency} {invoice.discount_amount:.2f}'])
line_items_data.append(['', '', '<b>Total:</b>', f'<b>{invoice.currency} {invoice.total_amount:.2f}</b>'])
# Add empty row for spacing before totals
line_items_data.append(['', '', '', ''])
# Create table
line_items_table = Table(
line_items_data,
colWidths=[3*inch, 1*inch, 1.25*inch, 1.25*inch]
colWidths=[3.2*inch, 0.8*inch, 1.25*inch, 1.25*inch]
)
num_items = len(items)
line_items_table.setStyle(TableStyle([
# Header row
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f3f4f6')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1f2937')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('TOPPADDING', (0, 0), (-1, 0), 12),
# Body rows
('FONTNAME', (0, 1), (-1, -4), 'Helvetica'),
('FONTSIZE', (0, 1), (-1, -4), 9),
('TEXTCOLOR', (0, 1), (-1, -4), colors.HexColor('#4b5563')),
('ROWBACKGROUNDS', (0, 1), (-1, -4), [colors.white, colors.HexColor('#f9fafb')]),
('ROWBACKGROUNDS', (0, 1), (-1, num_items), [colors.white, colors.HexColor('#f9fafb')]),
# Summary rows (last 3-4 rows)
('FONTNAME', (0, -4), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, -4), (-1, -1), 9),
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
('ALIGN', (3, 0), (3, -1), 'RIGHT'),
# Alignment
('ALIGN', (1, 0), (1, -1), 'CENTER'),
('ALIGN', (2, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
# Grid
('GRID', (0, 0), (-1, -4), 0.5, colors.HexColor('#e5e7eb')),
('LINEABOVE', (2, -4), (-1, -4), 1, colors.HexColor('#d1d5db')),
('LINEABOVE', (2, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
# Grid for items only
('LINEBELOW', (0, 0), (-1, 0), 1, colors.HexColor('#d1d5db')),
('LINEBELOW', (0, num_items), (-1, num_items), 1, colors.HexColor('#e5e7eb')),
# Padding
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 10),
('TOPPADDING', (0, 1), (-1, -1), 10),
('BOTTOMPADDING', (0, 1), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
elements.append(line_items_table)
elements.append(Spacer(1, 0.2*inch))
# Totals section - right aligned
totals_data = [
[Paragraph('Subtotal:', right_align_style), Paragraph(f'{invoice.currency} {float(invoice.subtotal):.2f}', right_bold_style)],
]
tax_amount = float(invoice.tax or 0)
if tax_amount > 0:
tax_rate = invoice.metadata.get('tax_rate', 0) if invoice.metadata else 0
totals_data.append([
Paragraph(f'Tax ({tax_rate}%):', right_align_style),
Paragraph(f'{invoice.currency} {tax_amount:.2f}', right_align_style)
])
discount_amount = float(invoice.metadata.get('discount_amount', 0)) if invoice.metadata else 0
if discount_amount > 0:
totals_data.append([
Paragraph('Discount:', right_align_style),
Paragraph(f'-{invoice.currency} {discount_amount:.2f}', right_align_style)
])
totals_data.append([
Paragraph('Total:', ParagraphStyle('TotalLabel', fontName='Helvetica-Bold', fontSize=12, textColor=colors.HexColor('#1f2937'), alignment=TA_RIGHT)),
Paragraph(f'{invoice.currency} {float(invoice.total):.2f}', ParagraphStyle('TotalValue', fontName='Helvetica-Bold', fontSize=12, textColor=colors.HexColor('#1f2937'), alignment=TA_RIGHT))
])
totals_table = Table(totals_data, colWidths=[1.5*inch, 1.5*inch])
totals_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('LINEABOVE', (0, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
]))
# Right-align the totals table
totals_wrapper = Table([[totals_table]], colWidths=[6.5*inch])
totals_wrapper.setStyle(TableStyle([
('ALIGN', (0, 0), (0, 0), 'RIGHT'),
]))
elements.append(totals_wrapper)
elements.append(Spacer(1, 0.4*inch))
# Payment information
if invoice.status == 'paid':
elements.append(Paragraph('<b>Payment Information:</b>', heading_style))
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceBefore=10, spaceAfter=15))
elements.append(Paragraph('PAYMENT INFORMATION', heading_style))
payment = invoice.payments.filter(status='succeeded').first()
if payment:
payment_method = payment.get_payment_method_display() if hasattr(payment, 'get_payment_method_display') else str(payment.payment_method)
payment_date = payment.processed_at.strftime("%B %d, %Y") if payment.processed_at else 'N/A'
payment_info = [
f'Payment Method: {payment.get_payment_method_display()}',
f'Paid On: {payment.processed_at.strftime("%B %d, %Y")}',
[Paragraph('Payment Method:', label_style), Paragraph(payment_method, value_style)],
[Paragraph('Paid On:', label_style), Paragraph(payment_date, value_style)],
]
if payment.manual_reference:
payment_info.append(f'Reference: {payment.manual_reference}')
for line in payment_info:
elements.append(Paragraph(line, normal_style))
payment_info.append([Paragraph('Reference:', label_style), Paragraph(payment.manual_reference, value_style)])
payment_table = Table(payment_info, colWidths=[1.5*inch, 3*inch])
payment_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
elements.append(payment_table)
elements.append(Spacer(1, 0.2*inch))
# Footer / Notes
if invoice.notes:
elements.append(Spacer(1, 0.2*inch))
elements.append(Paragraph('<b>Notes:</b>', heading_style))
elements.append(Paragraph('NOTES', heading_style))
elements.append(Paragraph(invoice.notes, normal_style))
# Terms
elements.append(Spacer(1, 0.3*inch))
elements.append(Paragraph('<b>Terms & Conditions:</b>', heading_style))
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date.')
elements.append(Paragraph(terms, normal_style))
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceAfter=15))
terms_style = ParagraphStyle(
'Terms',
parent=styles['Normal'],
fontSize=8,
textColor=colors.HexColor('#9ca3af'),
fontName='Helvetica',
)
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date. Thank you for your business!')
elements.append(Paragraph(f'Terms & Conditions: {terms}', terms_style))
# Footer with company info
elements.append(Spacer(1, 0.2*inch))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
textColor=colors.HexColor('#9ca3af'),
fontName='Helvetica',
alignment=TA_CENTER,
)
elements.append(Paragraph(f'Generated by IGNY8 • {company_email}', footer_style))
# Build PDF
doc.build(elements)

View File

@@ -21,6 +21,7 @@ from .views.stripe_views import (
StripeCheckoutView,
StripeCreditCheckoutView,
StripeBillingPortalView,
StripeReturnVerificationView,
stripe_webhook,
)
from .views.paypal_views import (
@@ -29,6 +30,7 @@ from .views.paypal_views import (
PayPalCreateSubscriptionOrderView,
PayPalCaptureOrderView,
PayPalCreateSubscriptionView,
PayPalReturnVerificationView,
paypal_webhook,
)
@@ -57,6 +59,7 @@ urlpatterns = [
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('stripe/verify-return/', StripeReturnVerificationView.as_view(), name='stripe-verify-return'),
path('webhooks/stripe/', stripe_webhook, name='stripe-webhook'),
# PayPal endpoints
@@ -65,5 +68,6 @@ urlpatterns = [
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('paypal/verify-return/', PayPalReturnVerificationView.as_view(), name='paypal-verify-return'),
path('webhooks/paypal/', paypal_webhook, name='paypal-webhook'),
]

View File

@@ -5,6 +5,8 @@ API endpoints for generating and downloading invoice PDFs
from django.http import HttpResponse
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from igny8_core.business.billing.models import Invoice
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
from igny8_core.business.billing.utils.errors import not_found_response
@@ -22,20 +24,46 @@ def download_invoice_pdf(request, invoice_id):
GET /api/v1/billing/invoices/<id>/pdf/
"""
try:
invoice = Invoice.objects.prefetch_related('line_items').get(
# Note: line_items is a JSONField, not a related model - no prefetch needed
invoice = Invoice.objects.select_related('account', 'account__owner', 'subscription', 'subscription__plan').get(
id=invoice_id,
account=request.user.account
)
except Invoice.DoesNotExist:
return not_found_response('Invoice', invoice_id)
# Generate PDF
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
# Return PDF response
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.invoice_number}.pdf"'
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
return response
try:
# Generate PDF
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
# Build descriptive filename: IGNY8-Invoice-INV123456-Growth-2026-01-08.pdf
plan_name = ''
if invoice.subscription and invoice.subscription.plan:
plan_name = invoice.subscription.plan.name.replace(' ', '-')
elif invoice.metadata and 'plan_name' in invoice.metadata:
plan_name = invoice.metadata['plan_name'].replace(' ', '-')
date_str = invoice.invoice_date.strftime('%Y-%m-%d') if invoice.invoice_date else ''
filename_parts = ['IGNY8', 'Invoice', invoice.invoice_number]
if plan_name:
filename_parts.append(plan_name)
if date_str:
filename_parts.append(date_str)
filename = '-'.join(filename_parts) + '.pdf'
# Return PDF response
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
return response
except Exception as e:
logger.error(f'Failed to generate PDF for invoice {invoice_id}: {str(e)}', exc_info=True)
return Response(
{'error': 'Failed to generate PDF', 'detail': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -20,6 +20,7 @@ Endpoints:
import json
import logging
from decimal import Decimal
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from django.db import transaction
@@ -33,7 +34,7 @@ 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 ..models import CreditPackage, Payment, Invoice, CreditTransaction, WebhookEvent
from ..services.paypal_service import PayPalService, PayPalConfigurationError, PayPalAPIError
from ..services.invoice_service import InvoiceService
from ..services.credit_service import CreditService
@@ -530,28 +531,50 @@ def paypal_webhook(request):
# Process event
event_type = body.get('event_type', '')
resource = body.get('resource', {})
event_id = body.get('id', '')
logger.info(f"PayPal webhook received: {event_type}")
logger.info(f"PayPal webhook received: {event_type} (ID: {event_id})")
# Store webhook event for audit trail
webhook_event, created = WebhookEvent.record_event(
event_id=event_id,
provider='paypal',
event_type=event_type,
payload=body
)
if not created:
logger.info(f"Duplicate PayPal webhook event {event_id}, skipping")
return Response({'status': 'duplicate'})
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'})
try:
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}")
# Mark webhook as successfully processed
webhook_event.mark_processed()
return Response({'status': 'success'})
except Exception as e:
logger.exception(f"Error processing PayPal webhook {event_type}: {e}")
# Mark webhook as failed
webhook_event.mark_failed(str(e))
return Response({'status': 'error', 'message': str(e)})
except Exception as e:
logger.exception(f"Error processing PayPal webhook: {e}")
return Response({'status': 'error', 'message': str(e)})
@@ -706,25 +729,30 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
# Update/create AccountPaymentMethod and mark as verified
from ..models import AccountPaymentMethod
# Get country code from account billing info
country_code = account.billing_country if account.billing_country else ''
AccountPaymentMethod.objects.update_or_create(
# First, clear default from ALL existing payment methods for this account
AccountPaymentMethod.objects.filter(account=account).update(is_default=False)
# Delete any existing PayPal payment method to avoid conflicts
AccountPaymentMethod.objects.filter(account=account, type='paypal').delete()
# Create fresh PayPal payment method
AccountPaymentMethod.objects.create(
account=account,
type='paypal',
defaults={
'display_name': 'PayPal',
'is_default': True,
'is_enabled': True,
'is_verified': True, # Mark verified after successful payment
'country_code': country_code, # Set country from account billing info
'metadata': {
'last_payment_at': timezone.now().isoformat(),
'paypal_order_id': capture_result.get('order_id'),
}
display_name='PayPal',
is_default=True,
is_enabled=True,
is_verified=True, # Mark verified after successful payment
country_code=country_code, # Set country from account billing info
metadata={
'last_payment_at': timezone.now().isoformat(),
'paypal_order_id': capture_result.get('order_id'),
}
)
# Set other payment methods as non-default
AccountPaymentMethod.objects.filter(account=account).exclude(type='paypal').update(is_default=False)
# Add subscription credits
if plan.included_credits and plan.included_credits > 0:
@@ -739,7 +767,7 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
}
)
# Update account status AND plan (like Stripe flow)
# Update account status, plan, AND payment_method (like Stripe flow)
update_fields = ['updated_at']
if account.status != 'active':
account.status = 'active'
@@ -747,6 +775,10 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
if account.plan_id != plan.id:
account.plan = plan
update_fields.append('plan')
# Always update payment_method to paypal after successful PayPal payment
if account.payment_method != 'paypal':
account.payment_method = 'paypal'
update_fields.append('payment_method')
account.save(update_fields=update_fields)
logger.info(
@@ -872,10 +904,16 @@ def _handle_subscription_activated(resource: dict):
description=f'PayPal Subscription: {plan.name}',
)
# Activate account
# Activate account and set payment method
update_fields = ['updated_at']
if account.status != 'active':
account.status = 'active'
account.save(update_fields=['status', 'updated_at'])
update_fields.append('status')
if account.payment_method != 'paypal':
account.payment_method = 'paypal'
update_fields.append('payment_method')
if update_fields != ['updated_at']:
account.save(update_fields=update_fields)
except Account.DoesNotExist:
logger.error(f"Account {custom_id} not found for PayPal subscription activation")
@@ -939,3 +977,106 @@ def _handle_subscription_payment_failed(resource: dict):
except Subscription.DoesNotExist:
pass
class PayPalReturnVerificationView(APIView):
"""
Verify PayPal payment on return from PayPal approval page.
Maps PayPal token to order_id and checks payment status.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
"""
Verify PayPal order status and return order_id for capture.
Query params:
- token: PayPal token from return URL (EC-xxx)
Returns order_id so frontend can call capture endpoint.
"""
token = request.query_params.get('token')
if not token:
return Response({
'error': 'token parameter is required'
}, status=status.HTTP_400_BAD_REQUEST)
account = request.user.account
try:
# Get PayPal service
service = PayPalService()
# Get order details from PayPal using token
# Unfortunately, PayPal doesn't have a direct token->order_id API
# So we need to store order_id before redirect, or use token as reference
# Check if we have a recent Payment record with this token in metadata
recent_payment = Payment.objects.filter(
account=account,
payment_method='paypal',
created_at__gte=timezone.now() - timedelta(hours=1),
metadata__icontains=token
).first()
if recent_payment and recent_payment.paypal_order_id:
order_id = recent_payment.paypal_order_id
else:
# Try to find order_id from token (stored in session/localStorage)
# This is why we need localStorage approach in frontend
return Response({
'error': 'order_id_not_found',
'message': 'Could not map PayPal token to order. Please try again.',
'token': token
}, status=status.HTTP_404_NOT_FOUND)
# Get order status from PayPal
order_details = service.get_order(order_id)
order_status = order_details.get('status') # 'CREATED', 'APPROVED', 'COMPLETED'
# Check if already captured
already_captured = Payment.objects.filter(
account=account,
paypal_order_id=order_id,
status='succeeded'
).exists()
response_data = {
'token': token,
'order_id': order_id,
'order_status': order_status,
'already_captured': already_captured,
'account_status': account.status,
'message': self._get_status_message(order_status, already_captured)
}
# If approved but not captured, return order_id for frontend to capture
if order_status == 'APPROVED' and not already_captured:
response_data['ready_to_capture'] = True
return Response(response_data)
except PayPalConfigurationError as e:
return Response({
'error': 'PayPal not configured',
'detail': str(e)
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
except Exception as e:
logger.error(f"Error verifying PayPal return: {e}")
return Response({
'error': 'Failed to verify PayPal payment',
'detail': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _get_status_message(self, order_status: str, already_captured: bool) -> str:
"""Get user-friendly status message"""
if order_status == 'COMPLETED' or already_captured:
return 'Payment successful! Your account has been activated.'
elif order_status == 'APPROVED':
return 'Payment approved! Completing your order...'
elif order_status == 'CREATED':
return 'Payment not approved yet. Please complete payment on PayPal.'
else:
return f'Payment status: {order_status}. Please contact support if you were charged.'

View File

@@ -23,7 +23,7 @@ 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 ..models import CreditPackage, Payment, Invoice, CreditTransaction, WebhookEvent
from ..services.stripe_service import StripeService, StripeConfigurationError
from ..services.payment_service import PaymentService
from ..services.invoice_service import InvoiceService
@@ -324,8 +324,21 @@ def stripe_webhook(request):
event_type = event['type']
data = event['data']['object']
event_id = event.get('id', '')
logger.info(f"Stripe webhook received: {event_type}")
logger.info(f"Stripe webhook received: {event_type} (ID: {event_id})")
# Store webhook event for audit trail
webhook_event, created = WebhookEvent.record_event(
event_id=event_id,
provider='stripe',
event_type=event_type,
payload=dict(event)
)
if not created:
logger.info(f"Duplicate Stripe webhook event {event_id}, skipping")
return Response({'status': 'duplicate'})
try:
if event_type == 'checkout.session.completed':
@@ -340,11 +353,15 @@ def stripe_webhook(request):
_handle_subscription_deleted(data)
else:
logger.info(f"Unhandled Stripe event type: {event_type}")
# Mark webhook as successfully processed
webhook_event.mark_processed()
return Response({'status': 'success'})
except Exception as e:
logger.exception(f"Error processing Stripe webhook {event_type}: {e}")
# Mark webhook as failed
webhook_event.mark_failed(str(e))
# Return 200 to prevent Stripe retries for application errors
# Log the error for debugging
return Response({'status': 'error', 'message': str(e)})
@@ -531,25 +548,33 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
# Update/create AccountPaymentMethod and mark as verified
from ..models import AccountPaymentMethod
from django.db import transaction
# Get country code from account billing info
country_code = account.billing_country if account.billing_country else ''
AccountPaymentMethod.objects.update_or_create(
account=account,
type='stripe',
defaults={
'display_name': 'Credit/Debit Card (Stripe)',
'is_default': True,
'is_enabled': True,
'is_verified': True, # Mark verified after successful payment
'country_code': country_code, # Set country from account billing info
'metadata': {
# Use atomic transaction to ensure consistency
with transaction.atomic():
# First, clear default from ALL existing payment methods for this account
AccountPaymentMethod.objects.filter(account=account).update(is_default=False)
# Delete any existing stripe payment method to avoid conflicts
AccountPaymentMethod.objects.filter(account=account, type='stripe').delete()
# Create fresh Stripe payment method
AccountPaymentMethod.objects.create(
account=account,
type='stripe',
display_name='Credit/Debit Card (Stripe)',
is_default=True,
is_enabled=True,
is_verified=True,
country_code=country_code,
metadata={
'last_payment_at': timezone.now().isoformat(),
'stripe_subscription_id': stripe_subscription_id,
}
}
)
# Set other payment methods as non-default
AccountPaymentMethod.objects.filter(account=account).exclude(type='stripe').update(is_default=False)
)
# Add initial credits from plan
if plan.included_credits and plan.included_credits > 0:
@@ -572,6 +597,10 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
if account.plan_id != plan.id:
account.plan = plan
update_fields.append('plan')
# Always update payment_method to stripe after successful Stripe payment
if account.payment_method != 'stripe':
account.payment_method = 'stripe'
update_fields.append('payment_method')
account.save(update_fields=update_fields)
logger.info(
@@ -808,3 +837,163 @@ def _handle_subscription_deleted(subscription_data: dict):
except Subscription.DoesNotExist:
logger.warning(f"Subscription not found for deletion: {subscription_id}")
class StripeReturnVerificationView(APIView):
"""
Verify Stripe payment on return from checkout.
Frontend calls this when user returns from Stripe to get updated account status.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
"""
Verify Stripe checkout session and return current account/subscription status.
Query params:
- session_id: Stripe checkout session ID
Returns updated account data so frontend can immediately show activation.
"""
session_id = request.query_params.get('session_id')
logger.info(f"[STRIPE-VERIFY] ========== VERIFICATION REQUEST ==========")
logger.info(f"[STRIPE-VERIFY] Session ID: {session_id}")
logger.info(f"[STRIPE-VERIFY] User: {request.user.email if request.user else 'Anonymous'}")
logger.info(f"[STRIPE-VERIFY] User ID: {request.user.id if request.user else 'N/A'}")
if not session_id:
logger.warning(f"[STRIPE-VERIFY] ❌ Missing session_id parameter")
return Response({
'error': 'session_id parameter is required'
}, status=status.HTTP_400_BAD_REQUEST)
account = request.user.account
logger.info(f"[STRIPE-VERIFY] Account: {account.name if account else 'No Account'}")
logger.info(f"[STRIPE-VERIFY] Account ID: {account.id if account else 'N/A'}")
logger.info(f"[STRIPE-VERIFY] Account Status: {account.status if account else 'N/A'}")
logger.info(f"[STRIPE-VERIFY] Stripe Customer ID: {account.stripe_customer_id if account else 'N/A'}")
try:
# Initialize Stripe service to get proper API key from IntegrationProvider
logger.info(f"[STRIPE-VERIFY] Initializing StripeService...")
service = StripeService()
logger.info(f"[STRIPE-VERIFY] ✓ StripeService initialized (sandbox={service.is_sandbox})")
# Retrieve session from Stripe to check payment status
# Pass api_key explicitly to ensure it's used
logger.info(f"[STRIPE-VERIFY] Retrieving checkout session from Stripe...")
session = stripe.checkout.Session.retrieve(
session_id,
api_key=service.provider.api_secret
)
logger.info(f"[STRIPE-VERIFY] ✓ Session retrieved successfully")
payment_status = session.get('payment_status') # 'paid', 'unpaid', 'no_payment_required'
customer_id = session.get('customer')
subscription_id = session.get('subscription')
mode = session.get('mode')
logger.info(f"[STRIPE-VERIFY] ===== STRIPE SESSION DATA =====")
logger.info(f"[STRIPE-VERIFY] payment_status: {payment_status}")
logger.info(f"[STRIPE-VERIFY] mode: {mode}")
logger.info(f"[STRIPE-VERIFY] customer: {customer_id}")
logger.info(f"[STRIPE-VERIFY] subscription: {subscription_id}")
logger.info(f"[STRIPE-VERIFY] amount_total: {session.get('amount_total')}")
logger.info(f"[STRIPE-VERIFY] currency: {session.get('currency')}")
logger.info(f"[STRIPE-VERIFY] metadata: {session.get('metadata', {})}")
# Check if webhook has processed this payment yet
logger.info(f"[STRIPE-VERIFY] Checking if payment record exists...")
# Note: metadata key is 'checkout_session_id' not 'stripe_checkout_session_id'
payment_exists = Payment.objects.filter(
account=account,
metadata__checkout_session_id=session_id
)
payment_record_exists = payment_exists.exists()
# IMPORTANT: Also check account status - if account is active, payment was processed
# This handles cases where payment record lookup fails but webhook succeeded
account_is_active = account.status == 'active'
payment_processed = payment_record_exists or (payment_status == 'paid' and account_is_active)
logger.info(f"[STRIPE-VERIFY] ===== DATABASE STATE =====")
logger.info(f"[STRIPE-VERIFY] Payment record exists: {payment_record_exists}")
logger.info(f"[STRIPE-VERIFY] Account is active: {account_is_active}")
logger.info(f"[STRIPE-VERIFY] payment_processed (combined): {payment_processed}")
if payment_record_exists:
payment = payment_exists.first()
logger.info(f"[STRIPE-VERIFY] Payment ID: {payment.id}")
logger.info(f"[STRIPE-VERIFY] Payment status: {payment.status}")
logger.info(f"[STRIPE-VERIFY] Payment amount: {payment.amount}")
# Get current subscription status
subscription = Subscription.objects.filter(account=account).order_by('-created_at').first()
logger.info(f"[STRIPE-VERIFY] Subscription exists: {subscription is not None}")
if subscription:
logger.info(f"[STRIPE-VERIFY] Subscription ID: {subscription.id}")
logger.info(f"[STRIPE-VERIFY] Subscription status: {subscription.status}")
logger.info(f"[STRIPE-VERIFY] Subscription plan: {subscription.plan.name if subscription.plan else 'N/A'}")
# Check invoices
from ..models import Invoice
recent_invoices = Invoice.objects.filter(account=account).order_by('-created_at')[:3]
logger.info(f"[STRIPE-VERIFY] Recent invoices count: {recent_invoices.count()}")
for inv in recent_invoices:
logger.info(f"[STRIPE-VERIFY] Invoice {inv.id}: status={inv.status}, amount={inv.total_amount}")
response_data = {
'session_id': session_id,
'payment_status': payment_status,
'payment_processed': payment_processed,
'account_status': account.status,
'subscription_status': subscription.status if subscription else None,
'message': self._get_status_message(payment_status, payment_processed)
}
# Only poll if payment is paid but account is NOT yet active
# If account is already active, no need to poll - we're done!
if payment_status == 'paid' and not account_is_active:
response_data['should_poll'] = True
response_data['poll_interval_ms'] = 1000 # Poll every second
logger.info(f"[STRIPE-VERIFY] ⏳ Payment paid but account not active yet, should_poll=True")
elif payment_status == 'paid' and account_is_active:
logger.info(f"[STRIPE-VERIFY] ✓ Payment paid AND account active - no polling needed")
logger.info(f"[STRIPE-VERIFY] ===== RESPONSE =====")
logger.info(f"[STRIPE-VERIFY] {response_data}")
logger.info(f"[STRIPE-VERIFY] ========== END VERIFICATION ==========")
return Response(response_data)
except StripeConfigurationError as e:
logger.error(f"Stripe not configured: {e}")
return Response({
'error': 'Stripe payment gateway not configured',
'detail': str(e)
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
except stripe.error.StripeError as e:
logger.error(f"Stripe error verifying session {session_id}: {e}")
return Response({
'error': 'Failed to verify payment with Stripe',
'detail': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error verifying Stripe return: {e}")
return Response({
'error': 'Failed to verify payment status',
'detail': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _get_status_message(self, payment_status: str, payment_processed: bool) -> str:
"""Get user-friendly status message"""
if payment_status == 'paid':
if payment_processed:
return 'Payment successful! Your account has been activated.'
else:
return 'Payment received! Processing your subscription...'
elif payment_status == 'unpaid':
return 'Payment not completed. Please try again.'
else:
return 'Payment status unknown. Please contact support if you were charged.'

View File

@@ -24,3 +24,6 @@ django-import-export==3.3.1
django-admin-rangefilter==0.11.1
django-celery-results==2.5.1
django-simple-history==3.4.0
# PDF Generation
reportlab>=4.0.0