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

File diff suppressed because it is too large Load Diff

View File

@@ -281,7 +281,16 @@ export default function BankTransferForm({
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
<span className="font-medium text-gray-900 dark:text-white">Amount to Transfer</span>
<span className="text-xl font-bold text-brand-600 dark:text-brand-400">
{invoice.currency === 'PKR' ? 'PKR ' : '$'}{invoice.total_amount || invoice.total}
{invoice.currency === 'PKR' ? 'PKR ' : '$'}
{(() => {
const amount = parseFloat(String(invoice.total_amount || invoice.total || 0));
// Round PKR to nearest thousand
if (invoice.currency === 'PKR') {
const rounded = Math.round(amount / 1000) * 1000;
return rounded.toLocaleString();
}
return amount.toFixed(2);
})()}
</span>
</div>
</div>

View File

@@ -11,7 +11,7 @@
* - Pakistan (PK): Stripe (Credit/Debit Card) + Bank Transfer
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
CreditCardIcon,
Building2Icon,
@@ -20,16 +20,19 @@ import {
Loader2Icon,
ArrowLeftIcon,
LockIcon,
RefreshCwIcon,
} from '../../icons';
import { Card } from '../ui/card';
import Badge from '../ui/badge/Badge';
import Button from '../ui/button/Button';
import { useToast } from '../ui/toast/ToastContainer';
import { useAuthStore } from '../../store/authStore';
import BankTransferForm from './BankTransferForm';
import {
Invoice,
getAvailablePaymentGateways,
subscribeToPlan,
getPayments,
type PaymentGateway,
} from '../../services/billing.api';
@@ -40,6 +43,18 @@ const PayPalIcon = ({ className }: { className?: string }) => (
</svg>
);
// Currency symbol helper
const getCurrencySymbol = (currency: string): string => {
const symbols: Record<string, string> = {
USD: '$',
PKR: 'Rs.',
EUR: '€',
GBP: '£',
INR: '₹',
};
return symbols[currency.toUpperCase()] || currency;
};
interface PaymentOption {
id: string;
type: PaymentGateway;
@@ -52,7 +67,10 @@ interface PendingPaymentViewProps {
invoice: Invoice | null;
userCountry: string;
planName: string;
planPrice: string;
planPrice: string; // USD price (from plan)
planPricePKR?: string; // PKR price (from invoice, if available)
currency?: string;
hasPendingBankTransfer?: boolean; // True if user has submitted bank transfer awaiting approval
onPaymentSuccess: () => void;
}
@@ -61,26 +79,79 @@ export default function PendingPaymentView({
userCountry,
planName,
planPrice,
planPricePKR,
currency = 'USD',
hasPendingBankTransfer = false,
onPaymentSuccess,
}: PendingPaymentViewProps) {
const toast = useToast();
const refreshUser = useAuthStore((state) => state.refreshUser);
const [selectedGateway, setSelectedGateway] = useState<PaymentGateway>('stripe');
const [loading, setLoading] = useState(false);
const [gatewaysLoading, setGatewaysLoading] = useState(true);
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([]);
const [showBankTransfer, setShowBankTransfer] = useState(false);
// Initialize bankTransferSubmitted from prop (persisted state)
const [bankTransferSubmitted, setBankTransferSubmitted] = useState(hasPendingBankTransfer);
const [checkingStatus, setCheckingStatus] = useState(false);
const isPakistan = userCountry === 'PK';
// SIMPLIFIED: Always show USD price, with PKR equivalent for Pakistan bank transfer users
const showPKREquivalent = isPakistan && selectedGateway === 'manual';
// Round PKR to nearest thousand for cleaner display
const pkrRaw = planPricePKR ? parseFloat(planPricePKR) : parseFloat(planPrice) * 278;
const pkrEquivalent = Math.round(pkrRaw / 1000) * 1000;
// Check if bank transfer has been approved
const checkPaymentStatus = useCallback(async () => {
if (!bankTransferSubmitted) return;
setCheckingStatus(true);
try {
// Refresh user data from backend
await refreshUser();
// Also check payments to see if any succeeded
const { results: payments } = await getPayments();
const hasSucceededPayment = payments.some(
(p: any) => p.status === 'succeeded' || p.status === 'completed'
);
if (hasSucceededPayment) {
toast?.success?.('Payment approved! Your account is now active.');
onPaymentSuccess();
}
} catch (error) {
console.error('Failed to check payment status:', error);
} finally {
setCheckingStatus(false);
}
}, [bankTransferSubmitted, refreshUser, onPaymentSuccess, toast]);
// Auto-check status every 30 seconds when awaiting approval
useEffect(() => {
if (!bankTransferSubmitted) return;
// Check immediately on mount
checkPaymentStatus();
// Then poll every 30 seconds
const interval = setInterval(checkPaymentStatus, 30000);
return () => clearInterval(interval);
}, [bankTransferSubmitted, checkPaymentStatus]);
// Load available payment gateways
useEffect(() => {
const loadGateways = async () => {
const isPK = userCountry === 'PK';
setGatewaysLoading(true);
try {
const gateways = await getAvailablePaymentGateways();
const gateways = await getAvailablePaymentGateways(userCountry);
const options: PaymentOption[] = [];
// Always show Stripe (Credit Card) if available
// Add Stripe if available
if (gateways.stripe) {
options.push({
id: 'stripe',
@@ -91,28 +162,26 @@ export default function PendingPaymentView({
});
}
// For Pakistan: show Bank Transfer
// For Global: show PayPal
if (isPakistan) {
if (gateways.manual) {
options.push({
id: 'bank_transfer',
type: 'manual',
name: 'Bank Transfer',
description: 'Pay via local bank transfer (PKR)',
icon: <Building2Icon className="w-6 h-6" />,
});
}
} else {
if (gateways.paypal) {
options.push({
id: 'paypal',
type: 'paypal',
name: 'PayPal',
description: 'Pay with your PayPal account',
icon: <PayPalIcon className="w-6 h-6" />,
});
}
// Add PayPal if available (Global users only, not PK)
if (gateways.paypal) {
options.push({
id: 'paypal',
type: 'paypal',
name: 'PayPal',
description: 'Pay with your PayPal account',
icon: <PayPalIcon className="w-6 h-6" />,
});
}
// Add Bank Transfer if available (Pakistan users only)
if (gateways.manual) {
options.push({
id: 'bank_transfer',
type: 'manual',
name: 'Bank Transfer',
description: 'Pay via local bank transfer (PKR equivalent)',
icon: <Building2Icon className="w-6 h-6" />,
});
}
setPaymentOptions(options);
@@ -135,7 +204,7 @@ export default function PendingPaymentView({
};
loadGateways();
}, [isPakistan]);
}, [userCountry]);
const handlePayNow = async () => {
if (!invoice) {
@@ -187,7 +256,8 @@ export default function PendingPaymentView({
invoice={invoice}
onSuccess={() => {
setShowBankTransfer(false);
onPaymentSuccess();
setBankTransferSubmitted(true);
// Don't call onPaymentSuccess immediately - wait for approval
}}
onCancel={() => setShowBankTransfer(false)}
/>
@@ -196,6 +266,132 @@ export default function PendingPaymentView({
);
}
// If bank transfer was submitted - show awaiting approval state
if (bankTransferSubmitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-white to-orange-50 dark:from-gray-900 dark:via-gray-900 dark:to-amber-950 py-12 px-4">
<div className="max-w-xl mx-auto">
{/* Header with Awaiting Badge */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900/30 mb-4 animate-pulse">
<Loader2Icon className="w-10 h-10 text-amber-600 dark:text-amber-400 animate-spin" />
</div>
<div className="flex items-center justify-center gap-2 mb-3">
<Badge variant="soft" tone="warning" size="md">
Awaiting Approval
</Badge>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Payment Submitted!
</h1>
<p className="text-gray-600 dark:text-gray-400">
Your bank transfer for <strong>{planName}</strong> is being verified
</p>
</div>
{/* Status Card */}
<Card className="p-6 mb-6 border-2 border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/10">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-amber-200 dark:bg-amber-800 rounded-full">
<Building2Icon className="w-5 h-5 text-amber-700 dark:text-amber-300" />
</div>
<div>
<h3 className="font-semibold text-amber-900 dark:text-amber-100">Bank Transfer</h3>
<p className="text-sm text-amber-700 dark:text-amber-400">Manual verification required</p>
</div>
</div>
<div className="space-y-3 py-4 border-t border-b border-amber-200 dark:border-amber-800">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{planName} Plan</span>
<span className="text-gray-900 dark:text-white">${planPrice} USD</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Amount Transferred (PKR)</span>
<span className="font-medium text-amber-700 dark:text-amber-300">PKR {pkrEquivalent.toLocaleString()}</span>
</div>
</div>
</Card>
{/* Info Pointers */}
<Card className="p-6 mb-6 border border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5 text-brand-500" />
What happens next?
</h3>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-bold shrink-0 mt-0.5">1</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">Verification in Progress</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Our team is reviewing your payment</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-bold shrink-0 mt-0.5">2</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">Email Confirmation</p>
<p className="text-sm text-gray-500 dark:text-gray-400">You'll receive an email once approved</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-bold shrink-0 mt-0.5">3</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">Account Activated</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Your subscription will be activated automatically</p>
</div>
</div>
</div>
</Card>
{/* Time Estimate Badge */}
<div className="flex items-center justify-center gap-2 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<AlertCircleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Expected approval time:</strong> Within 24 hours (usually faster)
</p>
</div>
{/* Check Status Button */}
<Button
variant="outline"
tone="brand"
size="lg"
onClick={checkPaymentStatus}
disabled={checkingStatus}
className="w-full mt-6"
>
{checkingStatus ? (
<span className="flex items-center justify-center gap-2">
<Loader2Icon className="w-5 h-5 animate-spin" />
Checking status...
</span>
) : (
<span className="flex items-center justify-center gap-2">
<RefreshCwIcon className="w-5 h-5" />
Check Payment Status
</span>
)}
</Button>
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
Status is checked automatically every 30 seconds
</p>
{/* Disabled Payment Options Notice */}
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-xl opacity-60">
<div className="flex items-center gap-2 mb-2">
<LockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Payment options disabled</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-500">
Other payment methods are disabled while your bank transfer is being verified.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-brand-950 py-12 px-4">
<div className="max-w-xl mx-auto">
@@ -222,13 +418,24 @@ export default function PendingPaymentView({
<div className="space-y-3 py-4 border-t border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{planName} Plan (Monthly)</span>
<span className="text-gray-900 dark:text-white">${planPrice}</span>
<span className="text-gray-900 dark:text-white">${planPrice} USD</span>
</div>
{showPKREquivalent && (
<div className="flex justify-between text-sm text-brand-600 dark:text-brand-400 font-medium">
<span>Bank Transfer Amount (PKR)</span>
<span>PKR {pkrEquivalent.toLocaleString()}</span>
</div>
)}
</div>
<div className="flex justify-between mt-4">
<span className="text-lg font-semibold text-gray-900 dark:text-white">Total</span>
<span className="text-2xl font-bold text-brand-600 dark:text-brand-400">${planPrice}</span>
<div className="text-right">
<span className="text-2xl font-bold text-brand-600 dark:text-brand-400">${planPrice} USD</span>
{showPKREquivalent && (
<div className="text-sm font-medium text-brand-500">≈ PKR {pkrEquivalent.toLocaleString()}</div>
)}
</div>
</div>
</Card>
@@ -320,16 +527,16 @@ export default function PendingPaymentView({
Processing...
</span>
) : selectedGateway === 'manual' ? (
'Continue to Bank Transfer'
'Continue to Bank Transfer Details'
) : (
`Pay $${planPrice} Now`
`Pay $${planPrice} USD Now`
)}
</Button>
{/* Info text */}
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
{selectedGateway === 'manual'
? 'You will receive bank details to complete your transfer'
? 'View bank account details and submit your transfer proof'
: 'You will be redirected to complete payment securely'
}
</p>

View File

@@ -166,7 +166,10 @@ const LayoutContent: React.FC = () => {
>
<AppHeader />
{/* Pending Payment Banner - Shows when account status is 'pending_payment' */}
<PendingPaymentBanner className="mx-4 mt-2 md:mx-6 md:mt-2" />
{/* Hidden on /account/plans since PendingPaymentView handles it there */}
{!window.location.pathname.startsWith('/account/plans') && (
<PendingPaymentBanner className="mx-4 mt-2 md:mx-6 md:mt-2" />
)}
<div className="p-3">
<PageLoaderWrapper>
<Outlet />

View File

@@ -5,6 +5,7 @@
*/
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import {
CreditCardIcon,
@@ -39,6 +40,7 @@ import PageHeader from '../../components/common/PageHeader';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { usePageLoading } from '../../context/PageLoadingContext';
import { formatCurrency } from '../../utils';
import { fetchAPI } from '../../services/api';
import {
getCreditBalance,
getCreditPackages,
@@ -69,12 +71,49 @@ import { useAuthStore } from '../../store/authStore';
import PayInvoiceModal from '../../components/billing/PayInvoiceModal';
import PendingPaymentView from '../../components/billing/PendingPaymentView';
/**
* Helper function to determine the effective currency based on billing country and payment method
* - PKR for Pakistan users using bank_transfer
* - USD for all other cases (Stripe, PayPal, or non-PK countries)
*/
const getCurrencyForDisplay = (billingCountry: string, paymentMethod?: string): string => {
if (billingCountry === 'PK' && paymentMethod === 'bank_transfer') {
return 'PKR';
}
return 'USD';
};
/**
* Convert USD price to PKR using approximate exchange rate
* Backend uses 278 PKR per USD
* Rounds to nearest thousand for cleaner display
*/
const convertUSDToPKR = (usdAmount: string | number): number => {
const amount = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
const pkr = amount * 278;
return Math.round(pkr / 1000) * 1000; // Round to nearest thousand
};
export default function PlansAndBillingPage() {
const { startLoading, stopLoading } = usePageLoading();
const toast = useToast();
const hasLoaded = useRef(false);
const { user } = useAuthStore.getState();
// FIX: Subscribe to user changes from Zustand store (reactive)
const user = useAuthStore((state) => state.user);
const refreshUser = useAuthStore((state) => state.refreshUser);
const isAwsAdmin = user?.account?.slug === 'aws-admin';
// Track if initial data has been loaded to prevent flash
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
// Payment processing state - shows beautiful loading UI
const [paymentProcessing, setPaymentProcessing] = useState<{
active: boolean;
stage: 'verifying' | 'processing' | 'finalizing' | 'activating';
message: string;
} | null>(null);
// UI States
const [error, setError] = useState<string>('');
@@ -99,7 +138,7 @@ export default function PlansAndBillingPage() {
const [availableGateways, setAvailableGateways] = useState<{ stripe: boolean; paypal: boolean; manual: boolean }>({
stripe: false,
paypal: false,
manual: true,
manual: false, // FIX: Initialize as false, will be set based on country
});
useEffect(() => {
@@ -109,65 +148,280 @@ export default function PlansAndBillingPage() {
// Handle payment gateway return URLs BEFORE loadData
const params = new URLSearchParams(window.location.search);
const success = params.get('success');
const sessionId = params.get('session_id'); // Stripe session ID
const canceled = params.get('canceled');
const purchase = params.get('purchase');
const paypalStatus = params.get('paypal');
const paypalToken = params.get('token'); // PayPal order ID
const paypalToken = params.get('token'); // PayPal token from URL
const planIdParam = params.get('plan_id');
const packageIdParam = params.get('package_id');
const { refreshUser } = useAuthStore.getState();
// Don't destructure from getState - use hooks above instead
// Handle PayPal return - MUST capture the order to complete payment
// Do this BEFORE loadData to ensure payment is processed first
// ============================================================================
// PAYMENT RETURN LOGGING - Comprehensive debug output
// ============================================================================
const LOG_PREFIX = '[PAYMENT-RETURN]';
console.group(`${LOG_PREFIX} Payment Return Flow Started`);
console.log(`${LOG_PREFIX} Full URL:`, window.location.href);
console.log(`${LOG_PREFIX} Session ID:`, sessionId);
// Detect which payment flow we're in
const paymentFlow =
(paypalStatus === 'success' && paypalToken) ? 'PAYPAL_SUCCESS' :
paypalStatus === 'cancel' ? 'PAYPAL_CANCEL' :
(success === 'true' && sessionId) ? 'STRIPE_SUCCESS_WITH_SESSION' :
success === 'true' ? 'STRIPE_SUCCESS_NO_SESSION' :
canceled === 'true' ? 'STRIPE_CANCELED' :
purchase === 'success' ? 'CREDIT_PURCHASE_SUCCESS' :
purchase === 'canceled' ? 'CREDIT_PURCHASE_CANCELED' :
'NO_PAYMENT_RETURN';
console.log(`${LOG_PREFIX} ===== DETECTED PAYMENT FLOW =====`);
console.log(`${LOG_PREFIX} Flow type:`, paymentFlow);
console.groupEnd();
// Handle PayPal return - Get order_id from localStorage and capture
if (paypalStatus === 'success' && paypalToken) {
// Import and capture PayPal order
import('../../services/billing.api').then(({ capturePayPalOrder }) => {
toast?.info?.('Completing PayPal payment...');
capturePayPalOrder(paypalToken, {
plan_id: planIdParam || undefined,
package_id: packageIdParam || undefined,
})
.then(() => {
toast?.success?.('Payment completed successfully!');
refreshUser().catch(() => {});
// Reload the page to get fresh data
window.history.replaceState({}, '', window.location.pathname);
window.location.reload();
})
.catch((err) => {
console.error('PayPal capture error:', err);
toast?.error?.(err?.message || 'Failed to complete PayPal payment');
window.history.replaceState({}, '', window.location.pathname);
});
console.group(`${LOG_PREFIX} PayPal Success Flow`);
// FIX: Retrieve order_id from localStorage (stored before redirect)
const storedOrderId = localStorage.getItem('paypal_order_id');
console.log(`${LOG_PREFIX} PayPal token from URL:`, paypalToken);
console.log(`${LOG_PREFIX} Stored order_id from localStorage:`, storedOrderId);
console.log(`${LOG_PREFIX} plan_id:`, planIdParam);
console.log(`${LOG_PREFIX} package_id:`, packageIdParam);
if (!storedOrderId) {
console.error(`${LOG_PREFIX} ❌ CRITICAL: No order_id in localStorage!`);
console.log(`${LOG_PREFIX} This means order_id was not saved before redirect to PayPal`);
console.groupEnd();
toast?.error?.('Payment not captured - order ID missing. Please try again.');
window.history.replaceState({}, '', window.location.pathname);
loadData(); // Still load data to show current state
return;
}
console.log(`${LOG_PREFIX} ✓ Order ID found, proceeding to capture...`);
// Show payment processing UI for PayPal
setPaymentProcessing({
active: true,
stage: 'processing',
message: 'Completing PayPal payment...'
});
// Clean URL immediately
window.history.replaceState({}, '', window.location.pathname);
// Import and capture PayPal order
import('../../services/billing.api').then(async ({ capturePayPalOrder }) => {
try {
const captureResponse = await capturePayPalOrder(storedOrderId, {
plan_id: planIdParam || undefined,
package_id: packageIdParam || undefined,
});
console.log(`${LOG_PREFIX} ✓ PayPal capture SUCCESS!`, captureResponse);
localStorage.removeItem('paypal_order_id');
// Update stage
setPaymentProcessing({
active: true,
stage: 'activating',
message: 'Activating your subscription...'
});
// Refresh user data - IMPORTANT: wait for this!
try {
await refreshUser();
console.log(`${LOG_PREFIX} ✓ User refreshed`);
} catch (refreshErr) {
console.error(`${LOG_PREFIX} User refresh failed:`, refreshErr);
}
// Short delay then complete
setTimeout(() => {
setPaymentProcessing(null);
toast?.success?.('Payment completed successfully!');
loadData();
}, 500);
} catch (err: any) {
console.error(`${LOG_PREFIX} ❌ PayPal capture FAILED:`, err);
localStorage.removeItem('paypal_order_id');
setPaymentProcessing(null);
toast?.error?.(err?.message || 'Failed to complete PayPal payment');
loadData();
}
});
console.groupEnd();
return; // Don't load data yet, wait for capture to complete
} else if (paypalStatus === 'cancel') {
console.log(`${LOG_PREFIX} PayPal payment was cancelled by user`);
localStorage.removeItem('paypal_order_id'); // Clear on cancellation
toast?.info?.('PayPal payment was cancelled');
window.history.replaceState({}, '', window.location.pathname);
}
// Handle Stripe success
else if (success === 'true') {
// Handle Stripe return - Verify payment with backend
else if (success === 'true' && sessionId) {
console.log(`${LOG_PREFIX} Stripe Success Flow - Session:`, sessionId);
// Show beautiful processing UI
setPaymentProcessing({
active: true,
stage: 'verifying',
message: 'Verifying your payment...'
});
// Clean URL immediately
window.history.replaceState({}, '', window.location.pathname);
fetchAPI(`/v1/billing/stripe/verify-return/?session_id=${sessionId}`)
.then(async (data) => {
console.log(`${LOG_PREFIX} Verification response:`, data);
if (data.payment_processed) {
// Payment already processed by webhook!
setPaymentProcessing({
active: true,
stage: 'activating',
message: 'Activating your subscription...'
});
// Refresh user to get updated account status
try {
await refreshUser();
console.log(`${LOG_PREFIX} User refreshed successfully`);
} catch (err) {
console.error(`${LOG_PREFIX} User refresh failed:`, err);
}
// Short delay for UX, then show success
setTimeout(() => {
setPaymentProcessing(null);
toast?.success?.('Payment successful! Your account is now active.');
loadData();
}, 500);
} else if (data.should_poll) {
// Webhook hasn't fired yet, poll for status
setPaymentProcessing({
active: true,
stage: 'processing',
message: 'Processing your payment...'
});
pollPaymentStatus(sessionId);
} else {
setPaymentProcessing(null);
toast?.warning?.(data.message || 'Payment verification pending');
loadData();
}
})
.catch(err => {
console.error(`${LOG_PREFIX} Verification failed:`, err);
setPaymentProcessing(null);
toast?.warning?.('Payment verification pending. Please refresh the page.');
loadData();
});
console.groupEnd();
return;
} else if (success === 'true') {
// Stripe return without session_id (old flow fallback)
console.log(`${LOG_PREFIX} Stripe success without session_id (legacy flow)`);
toast?.success?.('Subscription activated successfully!');
// Refresh user to get updated account status (removes pending_payment banner)
refreshUser().catch(() => {});
// Clean up URL
window.history.replaceState({}, '', window.location.pathname);
} else if (canceled === 'true') {
console.log(`${LOG_PREFIX} Stripe payment was cancelled`);
toast?.info?.('Payment was cancelled');
window.history.replaceState({}, '', window.location.pathname);
} else if (purchase === 'success') {
console.log(`${LOG_PREFIX} Credit purchase success`);
toast?.success?.('Credits purchased successfully!');
// Refresh user to get updated credit balance and account status
refreshUser().catch(() => {});
window.history.replaceState({}, '', window.location.pathname);
} else if (purchase === 'canceled') {
console.log(`${LOG_PREFIX} Credit purchase cancelled`);
toast?.info?.('Credit purchase was cancelled');
window.history.replaceState({}, '', window.location.pathname);
} else {
console.log(`${LOG_PREFIX} No payment return parameters detected, loading page normally`);
}
// Helper function to poll payment status with beautiful UI updates
async function pollPaymentStatus(sessionId: string, attempts = 0) {
const maxAttempts = 15; // Increased to 15 attempts
console.log(`${LOG_PREFIX} [POLL] Attempt ${attempts + 1}/${maxAttempts}`);
// Update processing stage based on attempt count
if (attempts === 3) {
setPaymentProcessing({
active: true,
stage: 'finalizing',
message: 'Finalizing your payment...'
});
}
if (attempts >= maxAttempts) {
console.warn(`${LOG_PREFIX} [POLL] Max attempts reached`);
setPaymentProcessing(null);
toast?.warning?.('Payment is being processed. Please refresh the page in a moment.');
loadData();
return;
}
// Faster initial polls (800ms), slower later (1.5s)
const pollDelay = attempts < 5 ? 800 : 1500;
setTimeout(async () => {
try {
const data = await fetchAPI(`/v1/billing/stripe/verify-return/?session_id=${sessionId}`);
if (data.payment_processed) {
console.log(`${LOG_PREFIX} [POLL] Payment processed!`);
// Show activating stage
setPaymentProcessing({
active: true,
stage: 'activating',
message: 'Activating your subscription...'
});
// Refresh user data
try {
await refreshUser();
console.log(`${LOG_PREFIX} [POLL] User refreshed`);
} catch (err) {
console.error(`${LOG_PREFIX} [POLL] User refresh failed:`, err);
}
// Short delay then complete
setTimeout(() => {
setPaymentProcessing(null);
toast?.success?.('Payment successful! Your account is now active.');
loadData();
}, 500);
} else {
// Continue polling
pollPaymentStatus(sessionId, attempts + 1);
}
} catch (pollErr) {
console.error(`${LOG_PREFIX} [POLL] Error:`, pollErr);
setPaymentProcessing(null);
toast?.warning?.('Please refresh page to see updated status.');
loadData();
}
}, pollDelay);
}
// Load data after handling return URLs
loadData();
}, []);
console.groupEnd();
}, [refreshUser]);
const handleError = (err: any, fallback: string) => {
const message = err?.message || fallback;
@@ -245,7 +499,9 @@ export default function PlansAndBillingPage() {
// Load available payment gateways and sync with user's payment method
try {
const gateways = await getAvailablePaymentGateways();
// FIX: Pass billing country to filter payment gateways correctly
const billingCountry = user?.account?.billing_country || 'US';
const gateways = await getAvailablePaymentGateways(billingCountry);
setAvailableGateways(gateways);
// Use user's verified payment method to set gateway
@@ -288,6 +544,7 @@ export default function PlansAndBillingPage() {
}
} finally {
stopLoading();
setInitialDataLoaded(true);
}
};
@@ -382,19 +639,33 @@ export default function PlansAndBillingPage() {
const accountPlanId = user?.account?.plan?.id;
const effectivePlanId = currentPlanId || accountPlanId;
const currentPlan = plans.find((p) => p.id === effectivePlanId) || user?.account?.plan;
const hasActivePlan = Boolean(effectivePlanId);
const hasPendingPayment = payments.some((p) => p.status === 'pending_approval');
// FIX: hasActivePlan should check account status, not just plan existence
const accountStatus = user?.account?.status || '';
const hasPendingInvoice = invoices.some((inv) => inv.status === 'pending');
const hasActivePlan = accountStatus === 'active'
&& effectivePlanId
&& currentPlan?.slug !== 'free'
&& !hasPendingInvoice;
const hasPendingPayment = payments.some((p) => p.status === 'pending_approval');
// Detect new user pending payment scenario:
// - account status is 'pending_payment'
// - user has never made a successful payment
const accountStatus = user?.account?.status || '';
const hasEverPaid = payments.some((p) => p.status === 'succeeded' || p.status === 'completed');
const isNewUserPendingPayment = accountStatus === 'pending_payment' && !hasEverPaid;
const pendingInvoice = invoices.find((inv) => inv.status === 'pending');
const billingCountry = (user?.account as any)?.billing_country || 'US';
// FIX: canManageBilling should check if user actually paid via Stripe
const userPaymentMethod = (user?.account as any)?.payment_method || '';
const hasStripeCustomerId = !!(user?.account as any)?.stripe_customer_id;
const canManageBilling = userPaymentMethod === 'stripe' && hasStripeCustomerId && hasActivePlan;
// Determine effective currency for display based on country and payment method
const effectiveCurrency = getCurrencyForDisplay(billingCountry, userPaymentMethod);
// Combined check: disable Buy Credits if no active plan OR has pending invoice
const canBuyCredits = hasActivePlan && !hasPendingInvoice;
@@ -409,18 +680,99 @@ export default function PlansAndBillingPage() {
return price > 0 && p.id !== effectivePlanId;
}).sort((a, b) => (Number(a.price) || 0) - (Number(b.price) || 0));
// PAYMENT PROCESSING OVERLAY - Beautiful full-page loading with breathing badge
if (paymentProcessing?.active) {
const stageConfig = {
verifying: { color: 'bg-blue-600', label: 'Verifying Payment' },
processing: { color: 'bg-amber-600', label: 'Processing Payment' },
finalizing: { color: 'bg-purple-600', label: 'Finalizing' },
activating: { color: 'bg-green-600', label: 'Activating Subscription' },
};
const config = stageConfig[paymentProcessing.stage];
// Use Modal-style overlay (matches app's default modal design)
return createPortal(
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-99999">
{/* Glass-like backdrop - same as Modal component */}
<div className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px] dark:bg-gray-900/70"></div>
{/* Content card - Modal style */}
<div className="relative w-full max-w-md mx-4 rounded-3xl bg-white dark:bg-gray-900 shadow-xl p-8">
{/* Main Loading Spinner */}
<div className="relative w-20 h-20 mx-auto mb-6">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-brand-500 animate-spin"></div>
<div className="absolute inset-2 rounded-full border-4 border-transparent border-t-brand-400 animate-spin" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
</div>
{/* Message */}
<h2 className="text-xl text-gray-900 dark:text-white font-bold text-center mb-2">
{paymentProcessing.message}
</h2>
<p className="text-gray-500 dark:text-gray-400 text-sm text-center mb-6">
Please don't close this page
</p>
{/* Stage Badge */}
<div className="flex justify-center">
<span className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-white text-sm font-medium ${config.color} shadow-md`}>
<Loader2Icon className="w-4 h-4 animate-spin" />
{config.label}
</span>
</div>
</div>
</div>,
document.body
);
}
// Show loading spinner until initial data is loaded
// This prevents the flash of billing dashboard before PendingPaymentView
if (!initialDataLoaded) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500 mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Loading billing information...</p>
</div>
</div>
);
}
// NEW USER PENDING PAYMENT - Show full-page payment view
// This is the simplified flow for users who just signed up with a paid plan
if (isNewUserPendingPayment && pendingInvoice) {
const planName = currentPlan?.name || pendingInvoice.subscription?.plan?.name || 'Selected Plan';
const planPrice = pendingInvoice.total_amount || pendingInvoice.total || '0';
const invoiceCurrency = pendingInvoice.currency || 'USD';
// Get USD price from plan, PKR price from invoice
const planUSDPrice = currentPlan?.price || pendingInvoice.subscription?.plan?.price || '0';
const invoicePKRPrice = invoiceCurrency === 'PKR' ? (pendingInvoice.total_amount || pendingInvoice.total || '0') : undefined;
// Check if user has a pending bank transfer (status = pending_approval with payment_method = bank_transfer)
const hasPendingBankTransfer = payments.some(
(p) => p.status === 'pending_approval' && (p.payment_method === 'bank_transfer' || p.payment_method === 'manual')
);
// Debug log for payment view
console.log('[PlansAndBillingPage] Rendering PendingPaymentView:', {
billingCountry,
invoiceCurrency,
planUSDPrice,
invoicePKRPrice,
planName,
hasPendingBankTransfer
});
return (
<PendingPaymentView
invoice={pendingInvoice}
userCountry={billingCountry}
planName={planName}
planPrice={planPrice}
planPrice={planUSDPrice}
planPricePKR={invoicePKRPrice}
currency={invoiceCurrency}
hasPendingBankTransfer={hasPendingBankTransfer}
onPaymentSuccess={() => {
// Refresh user and billing data
const { refreshUser } = useAuthStore.getState();
@@ -482,23 +834,23 @@ export default function PlansAndBillingPage() {
{/* SECTION 1: Current Plan Hero */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Plan Card */}
<Card className="lg:col-span-2 p-6 bg-gradient-to-br from-brand-500/10 via-purple-500/10 to-indigo-500/10 dark:from-brand-600/20 dark:via-purple-600/20 dark:to-indigo-600/20 border border-brand-200/50 dark:border-brand-700/50">
<Card className="lg:col-span-2 p-6 bg-gradient-to-br from-brand-200 via-brand-100 to-indigo-200 dark:from-brand-900/50 dark:via-brand-800/40 dark:to-indigo-900/40 border border-brand-300 dark:border-brand-600">
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
<h2 className="text-2xl font-bold text-brand-900 dark:text-white">
{currentPlan?.name || 'No Plan'}
</h2>
<Badge variant="solid" tone={hasActivePlan ? 'success' : 'warning'}>
{hasActivePlan ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="text-gray-600 dark:text-gray-400">
<p className="text-brand-700 dark:text-brand-200">
{currentPlan?.description || 'Select a plan to unlock features'}
</p>
</div>
<div className="flex items-center gap-2">
{availableGateways.stripe && hasActivePlan && (
{canManageBilling && (
<Button
variant="outline"
tone="neutral"
@@ -521,55 +873,61 @@ export default function PlansAndBillingPage() {
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-white/60 dark:bg-gray-800/40 rounded-xl">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-1">
<ZapIcon className="w-4 h-4 text-brand-500" />
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-brand-700 dark:text-brand-300 mb-1">
<ZapIcon className="w-4 h-4 text-brand-600" />
Credits
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
<div className="text-2xl font-bold text-brand-900 dark:text-white">
{creditBalance?.credits?.toLocaleString() || 0}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Available now</div>
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Available now</div>
</div>
<div className="p-4 bg-white/60 dark:bg-gray-800/40 rounded-xl">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-1">
<TrendingUpIcon className="w-4 h-4 text-purple-500" />
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300 mb-1">
<TrendingUpIcon className="w-4 h-4 text-purple-600" />
Used
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
<div className="text-2xl font-bold text-purple-900 dark:text-white">
{creditBalance?.credits_used_this_month?.toLocaleString() || 0}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">This month ({creditUsage}%)</div>
<div className="text-xs text-purple-600 dark:text-purple-400 mt-1">This month ({creditUsage}%)</div>
</div>
<div className="p-4 bg-white/60 dark:bg-gray-800/40 rounded-xl">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-1">
<CalendarIcon className="w-4 h-4 text-success-500" />
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-success-700 dark:text-success-300 mb-1">
<CalendarIcon className="w-4 h-4 text-success-600" />
Renews
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
<div className="text-2xl font-bold text-success-900 dark:text-white">
{currentSubscription?.current_period_end
? new Date(currentSubscription.current_period_end).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: ''}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Next billing</div>
<div className="text-xs text-success-600 dark:text-success-400 mt-1">Next billing</div>
</div>
<div className="p-4 bg-white/60 dark:bg-gray-800/40 rounded-xl">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-1">
<WalletIcon className="w-4 h-4 text-warning-500" />
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300 mb-1">
<WalletIcon className="w-4 h-4 text-amber-600" />
Monthly
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
${Number(currentPlan?.price || 0).toFixed(0)}
<div className="text-2xl font-bold text-amber-900 dark:text-white">
{formatCurrency(Number(currentPlan?.price || 0), 'USD')}
</div>
<div className="text-xs text-amber-600 dark:text-amber-400 mt-1">
{billingCountry === 'PK' ? (
<>≈ PKR {convertUSDToPKR(Number(currentPlan?.price || 0)).toLocaleString()}/mo</>
) : (
'Per month'
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Per month</div>
</div>
</div>
{/* Credit Usage Bar */}
<div className="mt-6 pt-6 border-t border-white/30 dark:border-gray-700/30">
<div className="mt-6 pt-6 border-t border-brand-200/50 dark:border-brand-700/30">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Credit Usage</span>
<span className="font-medium text-gray-900 dark:text-white">
<span className="text-brand-700 dark:text-brand-300">Credit Usage</span>
<span className="font-medium text-brand-900 dark:text-white">
{creditBalance?.credits_used_this_month?.toLocaleString() || 0} / {creditBalance?.plan_credits_per_month?.toLocaleString() || 0}
</span>
</div>
@@ -734,8 +1092,13 @@ export default function PlansAndBillingPage() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">credits</div>
<div className="font-semibold text-brand-600 dark:text-brand-400 group-hover:text-brand-700 dark:group-hover:text-brand-300">
${pkg.price}
{formatCurrency(pkg.price, 'USD')}
</div>
{billingCountry === 'PK' && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
≈ PKR {convertUSDToPKR(pkg.price).toLocaleString()}
</div>
)}
{purchaseLoadingId === pkg.id && (
<Loader2Icon className="w-4 h-4 animate-spin mt-2" />
)}
@@ -780,7 +1143,14 @@ export default function PlansAndBillingPage() {
</div>
</div>
<div className="text-right">
<div className="font-bold text-gray-900 dark:text-white">${plan.price}/mo</div>
<div className="font-bold text-gray-900 dark:text-white">
{formatCurrency(plan.price, 'USD')}/mo
</div>
{billingCountry === 'PK' && (
<div className="text-xs text-gray-500 dark:text-gray-400">
≈ PKR {convertUSDToPKR(plan.price).toLocaleString()}/mo
</div>
)}
<Button
size="sm"
variant="ghost"
@@ -845,7 +1215,16 @@ export default function PlansAndBillingPage() {
<td className="px-6 py-3 text-gray-600 dark:text-gray-400">
{new Date(invoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</td>
<td className="px-6 py-3 text-center font-semibold text-gray-900 dark:text-white">{formatCurrency(invoice.total_amount, invoice.currency)}</td>
<td className="px-6 py-3 text-center">
<div className="font-semibold text-gray-900 dark:text-white">
{formatCurrency(invoice.total_amount, 'USD')}
</div>
{billingCountry === 'PK' && invoice.currency === 'USD' && (
<div className="text-xs text-gray-500 dark:text-gray-400">
PKR {convertUSDToPKR(invoice.total_amount).toLocaleString()}
</div>
)}
</td>
<td className="px-6 py-3 text-center">
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
{invoice.status}
@@ -900,7 +1279,15 @@ export default function PlansAndBillingPage() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{userPaymentMethods.map((method: any) => (
{userPaymentMethods
.filter((method: any) => {
// Bank transfer is only available for Pakistani users
if (method.type === 'bank_transfer' && billingCountry !== 'PK') {
return false;
}
return true;
})
.map((method: any) => (
<div
key={method.id}
className={`p-4 border rounded-xl transition-all ${

View File

@@ -340,11 +340,11 @@ export default function UsageDashboardPage() {
{/* SECTION 1: Credit Overview - Hero Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Credit Card */}
<Card className="lg:col-span-2 p-6 bg-gradient-to-br from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 border-0">
<Card className="lg:col-span-2 p-6 bg-gradient-to-br from-brand-200 via-brand-100 to-indigo-200 dark:from-brand-900/50 dark:via-brand-800/40 dark:to-indigo-900/40 border border-brand-300 dark:border-brand-600">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">Credit Balance</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Your available credits for AI operations</p>
<h2 className="text-lg font-semibold text-brand-900 dark:text-white mb-1">Credit Balance</h2>
<p className="text-sm text-brand-700 dark:text-brand-200">Your available credits for AI operations</p>
</div>
<Link to="/account/plans">
<Button size="sm" variant="primary" tone="brand">
@@ -355,30 +355,30 @@ export default function UsageDashboardPage() {
<div className="grid grid-cols-3 gap-6">
<div>
<div className="text-4xl font-bold text-brand-600 dark:text-brand-400 mb-1">
<div className="text-4xl font-bold text-brand-700 dark:text-brand-400 mb-1">
{creditBalance?.credits.toLocaleString() || 0}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Available Now</div>
<div className="text-sm text-brand-600 dark:text-brand-300">Available Now</div>
</div>
<div>
<div className="text-4xl font-bold text-purple-600 dark:text-purple-400 mb-1">
<div className="text-4xl font-bold text-purple-700 dark:text-purple-400 mb-1">
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Used This Month</div>
<div className="text-sm text-purple-600 dark:text-purple-300">Used This Month</div>
</div>
<div>
<div className="text-4xl font-bold text-gray-900 dark:text-white mb-1">
<div className="text-4xl font-bold text-indigo-800 dark:text-white mb-1">
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Monthly Allowance</div>
<div className="text-sm text-indigo-600 dark:text-indigo-300">Monthly Allowance</div>
</div>
</div>
{/* Credit Usage Bar */}
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Monthly Usage</span>
<span className="font-medium text-gray-900 dark:text-white">{creditPercentage}%</span>
<span className="text-brand-700 dark:text-brand-300">Monthly Usage</span>
<span className="font-medium text-brand-900 dark:text-white">{creditPercentage}%</span>
</div>
<div className="h-3 bg-white/50 dark:bg-gray-800/50 rounded-full overflow-hidden">
<div

View File

@@ -493,13 +493,29 @@ export async function getInvoiceDetail(invoiceId: number): Promise<Invoice> {
}
export async function downloadInvoicePDF(invoiceId: number): Promise<Blob> {
const response = await fetch(`/v1/billing/invoices/${invoiceId}/download_pdf/`, {
const token = localStorage.getItem('access_token') ||
(() => {
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
return parsed?.state?.token || null;
}
} catch (e) {}
return null;
})();
// Use the full API URL from the api.ts module
const apiUrl = (await import('./api')).API_BASE_URL;
const response = await fetch(`${apiUrl}/v1/billing/invoices/${invoiceId}/download_pdf/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('PDF download failed:', response.status, errorText);
throw new Error('Failed to download invoice PDF');
}
@@ -1374,9 +1390,11 @@ export async function isPayPalConfigured(): Promise<boolean> {
}
/**
* Get available payment gateways
* Get available payment gateways based on user's country
* - Pakistan (PK): Stripe + Bank Transfer (manual), NO PayPal
* - Global: Stripe + PayPal, NO Bank Transfer
*/
export async function getAvailablePaymentGateways(): Promise<{
export async function getAvailablePaymentGateways(userCountry?: string): Promise<{
stripe: boolean;
paypal: boolean;
manual: boolean;
@@ -1386,10 +1404,14 @@ export async function getAvailablePaymentGateways(): Promise<{
isPayPalConfigured(),
]);
const isPakistan = userCountry?.toUpperCase() === 'PK';
return {
stripe: stripeAvailable,
paypal: paypalAvailable,
manual: true, // Manual payment is always available
// PayPal: available globally EXCEPT Pakistan
paypal: !isPakistan && paypalAvailable,
// Manual (Bank Transfer): available for Pakistan only
manual: isPakistan,
};
}
@@ -1404,10 +1426,14 @@ export async function subscribeToPlan(
switch (gateway) {
case 'stripe': {
const session = await createStripeCheckout(planId, options);
return { redirect_url: session.checkout_url };
// FIX: Return URL should include session_id for verification
const redirectUrl = new URL(session.checkout_url);
return { redirect_url: redirectUrl.toString() };
}
case 'paypal': {
const order = await createPayPalSubscriptionOrder(planId, options);
// FIX: Store order_id in localStorage before redirect
localStorage.setItem('paypal_order_id', order.order_id);
return { redirect_url: order.approval_url };
}
case 'manual':
@@ -1432,6 +1458,8 @@ export async function purchaseCredits(
}
case 'paypal': {
const order = await createPayPalCreditOrder(packageId, options);
// FIX: Store order_id in localStorage before redirect
localStorage.setItem('paypal_order_id', order.order_id);
return { redirect_url: order.approval_url };
}
case 'manual':