From 7ad1f6bdff417b3c748e400dad2673123b6fa97a Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Thu, 8 Jan 2026 00:12:41 +0000 Subject: [PATCH] FInal bank, stripe and paypal sandbox completed --- backend/igny8_core/auth/admin.py | 58 +- backend/igny8_core/auth/models.py | 8 + backend/igny8_core/auth/serializers.py | 2 +- .../business/billing/billing_views.py | 67 +- backend/igny8_core/business/billing/models.py | 14 + .../billing/services/invoice_service.py | 124 +- .../business/billing/services/pdf_service.py | 338 ++++-- backend/igny8_core/business/billing/urls.py | 4 + .../billing/views/invoice_pdf_views.py | 50 +- .../business/billing/views/paypal_views.py | 213 +++- .../business/billing/views/stripe_views.py | 223 +++- backend/requirements.txt | 3 + .../PAYMENT-SYSTEM-REFACTOR-PLAN.md | 1015 +++++++++++++++++ .../components/billing/BankTransferForm.tsx | 11 +- .../components/billing/PendingPaymentView.tsx | 273 ++++- frontend/src/layout/AppLayout.tsx | 5 +- .../src/pages/account/PlansAndBillingPage.tsx | 525 +++++++-- .../src/pages/account/UsageDashboardPage.tsx | 22 +- frontend/src/services/billing.api.ts | 42 +- 19 files changed, 2622 insertions(+), 375 deletions(-) diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index 4375a8cc..f51a3991 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -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) diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 29cfa6b4..53422d12 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -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) diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 2df3c492..43cac64f 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -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'] diff --git a/backend/igny8_core/business/billing/billing_views.py b/backend/igny8_core/business/billing/billing_views.py index e2cd4505..8ffa0e04 100644 --- a/backend/igny8_core/business/billing/billing_views.py +++ b/backend/igny8_core/business/billing/billing_views.py @@ -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, diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 36e9706e..bd8671ba 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -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 diff --git a/backend/igny8_core/business/billing/services/invoice_service.py b/backend/igny8_core/business/billing/services/invoice_service.py index 979a64d0..be24a29c 100644 --- a/backend/igny8_core/business/billing/services/invoice_service.py +++ b/backend/igny8_core/business/billing/services/invoice_service.py @@ -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( diff --git a/backend/igny8_core/business/billing/services/pdf_service.py b/backend/igny8_core/business/billing/services/pdf_service.py index ccec86dc..69830a72 100644 --- a/backend/igny8_core/business/billing/services/pdf_service.py +++ b/backend/igny8_core/business/billing/services/pdf_service.py @@ -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 = [ - ['From:', f'Invoice #: {invoice.invoice_number}'], - [getattr(settings, 'COMPANY_NAME', 'Igny8'), f'Date: {invoice.created_at.strftime("%B %d, %Y")}'], - [getattr(settings, 'COMPANY_ADDRESS', ''), f'Due Date: {invoice.due_date.strftime("%B %d, %Y")}'], - [getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL), f'Status: {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('Bill To:', 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('Items:', 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(['', '', 'Subtotal:', f'{invoice.currency} {invoice.subtotal:.2f}']) - - 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(['', '', 'Total:', f'{invoice.currency} {invoice.total_amount:.2f}']) + # 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('Payment Information:', 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('Notes:', 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('Terms & Conditions:', 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) diff --git a/backend/igny8_core/business/billing/urls.py b/backend/igny8_core/business/billing/urls.py index 56d98bff..3d66f110 100644 --- a/backend/igny8_core/business/billing/urls.py +++ b/backend/igny8_core/business/billing/urls.py @@ -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'), ] diff --git a/backend/igny8_core/business/billing/views/invoice_pdf_views.py b/backend/igny8_core/business/billing/views/invoice_pdf_views.py index 332130ad..d369e910 100644 --- a/backend/igny8_core/business/billing/views/invoice_pdf_views.py +++ b/backend/igny8_core/business/billing/views/invoice_pdf_views.py @@ -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//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 + ) diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py index 2e03bf11..006b4c28 100644 --- a/backend/igny8_core/business/billing/views/paypal_views.py +++ b/backend/igny8_core/business/billing/views/paypal_views.py @@ -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.' + diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py index e565521b..fede16c7 100644 --- a/backend/igny8_core/business/billing/views/stripe_views.py +++ b/backend/igny8_core/business/billing/views/stripe_views.py @@ -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.' + diff --git a/backend/requirements.txt b/backend/requirements.txt index 3c806bd3..adcd173c 100755 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md index 1e5298b2..cbda4229 100644 --- a/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md +++ b/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md @@ -1340,6 +1340,1021 @@ npm run lint 1. **Signup flow simplified** - No payment redirect from signup 2. **Single payment interface** - All payments through /account/plans 3. **Security issues fixed** - Webhook verification, idempotency + +--- + +# 🚨 CRITICAL ISSUES AFTER REFACTOR + +## 🔴 **NEW CRITICAL BUGS INTRODUCED IN RECENT REFACTORS** + +### Issue #1: Payment Gateway Country Filtering Broken + +| # | Severity | Issue | Location | Line | +|---|----------|-------|----------|------| +| 1 | 🔴 CRITICAL | `getAvailablePaymentGateways()` called **without country code** - returns PayPal for ALL users including PK | PlansAndBillingPage.tsx | ~251 | +| 2 | 🔴 CRITICAL | `getAvailablePaymentGateways()` called **without country code** in PaymentGatewaySelector | PaymentGatewaySelector.tsx | - | +| 3 | 🔴 CRITICAL | `availableGateways` initialized with `manual: true` (Bank Transfer shows for ALL users by default) | PlansAndBillingPage.tsx | - | +| 4 | 🔴 CRITICAL | PayPal button visible in Buy Credits section for PK users | PlansAndBillingPage.tsx | - | +| 5 | 🔴 CRITICAL | PayPal button visible in Upgrade Modal for PK users | PlansAndBillingPage.tsx | - | +| 6 | 🔴 CRITICAL | Bank Transfer button visible in Upgrade Modal for NON-PK users | PlansAndBillingPage.tsx | - | + +**Root Cause:** +```tsx +// CURRENT (BROKEN) - Line ~251: +const gateways = await getAvailablePaymentGateways(); + +// SHOULD BE: +const billingCountry = user?.account?.billing_country || 'US'; +const gateways = await getAvailablePaymentGateways(billingCountry); +``` + +**Impact:** +- PK users can see and attempt PayPal payments (NOT ALLOWED) +- Non-PK users see Bank Transfer option (WRONG) +- Payment method selection doesn't respect country restrictions + +--- + +### Issue #2: Stripe Return Flow Broken After Refactor + +**Status:** 🔴 **BROKEN** - Payments not activating accounts + +**Evidence:** +- User completes payment on Stripe +- Stripe redirects to `/account/plans?success=true` +- Invoice remains `status='pending'` +- Account remains `status='pending_payment'` +- Notification shows "payment successful" but account not activated + +**Root Cause:** +1. Frontend redirect URL changed from `/plans` to `/account/plans` +2. No backend endpoint to handle return URL verification +3. Relies entirely on webhook which may be delayed +4. Frontend has no way to force-check payment status + +**Missing Flow:** +``` +User Returns → Check URL params → ❌ NO BACKEND CALL + → ❌ No status verification + → Shows old cached status +``` + +**Should Be:** +``` +User Returns → Extract session_id from URL + → Call /v1/billing/verify-payment-status/?session_id=xxx + → Backend checks with Stripe + → Return updated account/subscription status + → Refresh UI +``` + +--- + +### Issue #3: PayPal Return Flow Broken After Refactor + +**Status:** 🔴 **BROKEN** - "Payment not captured" error + +**Evidence:** +- User approves payment on PayPal +- PayPal redirects to `/account/plans?paypal=success&token=xxx` +- Frontend shows "Payment not captured" error +- Order remains uncaptured + +**Root Cause:** +1. Frontend expects to call `/v1/billing/paypal/capture-order/` on return +2. But `order_id` is not in URL params (PayPal uses `token` param) +3. Capture endpoint requires `order_id` not `token` +4. Mismatch between PayPal redirect params and capture endpoint + +**Current Broken Flow:** +``` +PayPal Redirect → ?paypal=success&token=EC-xxx + → Frontend tries to extract order_id + → ❌ No order_id in URL + → ❌ Capture fails +``` + +**Should Be:** +``` +PayPal Redirect → ?paypal=success&token=EC-xxx + → Frontend stores order_id before redirect + → On return, retrieve order_id from storage + → Call capture with correct order_id +``` + +--- + +### Issue #4: Invoice Currency Mismatch + +| # | Severity | Issue | Location | +|---|----------|-------|----------| +| 7 | 🟡 HIGH | Buy Credits always shows `$` (USD) even for PK users using Bank Transfer (should show PKR) | PlansAndBillingPage.tsx | +| 8 | 🟡 HIGH | Quick Upgrade cards always show `$` (USD) for all users | PlansAndBillingPage.tsx | +| 9 | 🟡 HIGH | Upgrade Modal shows `$` for all plans even when Bank Transfer (PKR) selected | PlansAndBillingPage.tsx | +| 10 | 🟢 MEDIUM | Invoice created with currency depends on `account.payment_method` which may not be set at signup | invoice_service.py | + +**Root Cause:** +- Invoice currency determined at creation time based on `account.payment_method` +- At signup, `account.payment_method` may not be set +- Frontend hardcodes `$` symbol +- No dynamic currency switching based on selected gateway + +--- + +### Issue #5: "Manage Billing" Logic Error + +| # | Severity | Issue | Location | +|---|----------|-------|----------| +| 11 | 🟡 HIGH | "Manage Billing" shows for users with Stripe available, not users who **paid via Stripe** | PlansAndBillingPage.tsx | + +**Root Cause:** +```tsx +// CURRENT (WRONG): +const canManageBilling = availableGateways.stripe && hasActivePlan; + +// SHOULD BE: +const canManageBilling = user?.account?.payment_method === 'stripe' + && user?.account?.stripe_customer_id + && hasActivePlan; +``` + +**Impact:** +- PK users who paid via Bank Transfer see "Manage Billing" button +- Clicking it tries to redirect to Stripe portal (which doesn't exist for them) + +--- + +### Issue #6: Plan Status Display Logic Error + +| # | Audit Issue | Claimed Status | Actual Status | +|---|-------------|----------------|---------------| +| 1 | Plan shows "Active" even with unpaid invoice | ✅ Claimed fixed | ❌ Still using `hasActivePlan` based on plan existence only | +| 2 | "Manage Billing" shown to non-Stripe users | ✅ Claimed fixed | ❌ Shows for anyone with `availableGateways.stripe` | +| 3 | Cancel Subscription available when account pending | ✅ Claimed fixed | ❌ Logic not properly implemented | +| 4 | Payment gateway selection not synced with account | ✅ Claimed fixed | ❌ Gateway determined without country | + +**Root Cause:** +```tsx +// CURRENT (WRONG): +const hasActivePlan = !!user?.account?.plan && user?.account?.plan?.slug !== 'free'; + +// SHOULD CHECK: +const hasActivePlan = user?.account?.status === 'active' + && user?.account?.plan?.slug !== 'free' + && !hasPendingInvoice; +``` + +--- + +### Issue #7: Backend Payment Method Endpoint Returns Wrong Data + +| # | Severity | Issue | Location | +|---|----------|-------|----------| +| 12 | 🟢 MEDIUM | Backend `list_payment_methods` endpoint returns ALL enabled methods ignoring country_code | billing_views.py | + +**Evidence:** +```python +# Current endpoint ignores country filtering +def list_payment_methods(request): + methods = PaymentMethodConfig.objects.filter(is_enabled=True) + # ❌ No country-based filtering + return Response(methods) +``` + +**Should Be:** +```python +def list_payment_methods(request): + country = request.query_params.get('country_code', 'US') + methods = PaymentMethodConfig.objects.filter( + is_enabled=True, + country_code=country + ) + return Response(methods) +``` + +--- + +## 🔴 **CRITICAL: Payment Return Flows NOT WORKING** + +### Stripe Return Flow - BROKEN ❌ + +**Current State:** +``` +User pays on Stripe + ↓ +Stripe redirects to: /account/plans?success=true&session_id=cs_xxx + ↓ +Frontend PlansAndBillingPage loads + ↓ +❌ NO API CALL to verify payment + ↓ +Shows cached user data (still pending_payment) + ↓ +User sees "Complete your payment" (even though they just paid) + ↓ +Webhook eventually fires (5-30 seconds later) + ↓ +Page doesn't auto-refresh + ↓ +User must manually refresh to see activation +``` + +**What's Missing:** +1. No `/v1/billing/verify-stripe-return/` endpoint +2. Frontend doesn't extract `session_id` from URL +3. No status polling/refresh mechanism +4. No loading state while verification happens + +--- + +### PayPal Return Flow - BROKEN ❌ + +**Current State:** +``` +User approves on PayPal + ↓ +PayPal redirects to: /account/plans?paypal=success&token=EC-xxx&PayerID=yyy + ↓ +Frontend tries to capture payment + ↓ +❌ Can't find order_id (only has token) + ↓ +Capture API call fails + ↓ +Shows error: "Payment not captured" + ↓ +User stuck with pending payment +``` + +**What's Missing:** +1. `order_id` not persisted before redirect +2. No mapping from `token` → `order_id` +3. Capture endpoint can't proceed without `order_id` + +--- + +### Bank Transfer Flow - Working ✅ + +**Current State:** (This one works correctly) +``` +User submits bank transfer proof + ↓ +POST /v1/billing/payments/manual/ + ↓ +Creates Payment record (status=pending) + ↓ +Admin approves payment + ↓ +Account activated + ↓ +✅ Works as expected +``` + +--- + +## 📊 COMPLETE END-TO-END PAYMENT WORKFLOW DIAGRAMS + +### 🔵 STRIPE FLOW - US/GLOBAL USERS (Subscription) + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 1: SIGNUP │ +└────────────────────────────────────────────────────────────────────┘ + +User visits /signup + ↓ +Fills form: email, password, name, country=US, plan=Starter ($29/mo) + ↓ +Clicks "Create Account" + ↓ +POST /v1/auth/register/ + └─> Body: { + email, password, username, + billing_country: 'US', + plan_slug: 'starter' + } + +BACKEND ACTIONS: + 1. Create User + 2. Create Account (status='pending_payment', plan=Starter, credits=0) + 3. Create Subscription (status='pending_payment') + 4. Create Invoice (status='pending', currency='USD', amount=29.00) + 5. Return: { user, tokens, ... } // ❌ NO checkout_url + +Frontend redirects to → /account/plans + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 2: PAYMENT PAGE │ +└────────────────────────────────────────────────────────────────────┘ + +/account/plans loads + ↓ +Checks: accountStatus='pending_payment' && hasEverPaid=false + ↓ +Shows PendingPaymentView + ↓ +Displays Invoice #INV-260100001, Amount: $29.00 + ↓ +Payment methods: [Stripe ✅, PayPal ✅] (country=US) + ↓ +User selects Stripe + ↓ +Clicks "Pay $29.00" + ↓ + +POST /v1/billing/stripe/subscribe/ + └─> Body: { + plan_slug: 'starter', + payment_method: 'stripe', + return_url: 'http://localhost:5173/account/plans?success=true', + cancel_url: 'http://localhost:5173/account/plans?canceled=true' + } + +BACKEND ACTIONS (stripe_views.py - StripeSubscribeView): + 1. Get plan, account + 2. Create Stripe Checkout Session + - mode: 'subscription' + - line_items: [{ price: plan.stripe_price_id, quantity: 1 }] + - customer_email: user.email + - metadata: { account_id, plan_id, type: 'subscription' } + - success_url: return_url + - cancel_url: cancel_url + 3. Return { redirect_url: session.url } + +Frontend redirects to → session.url (Stripe hosted page) + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 3: STRIPE PAYMENT │ +└────────────────────────────────────────────────────────────────────┘ + +User on Stripe page + ↓ +Enters card details + ↓ +Clicks "Pay" + ↓ +Stripe processes payment + ↓ +Payment succeeds + ↓ +Stripe redirects to → /account/plans?success=true&session_id=cs_xxx + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 4: RETURN TO APP (❌ BROKEN) │ +└────────────────────────────────────────────────────────────────────┘ + +Browser lands on /account/plans?success=true&session_id=cs_xxx + ↓ +PlansAndBillingPage.tsx loads + ↓ +useEffect runs → loadBillingData() + ↓ +❌ BUG: No code to check URL params for session_id +❌ BUG: No API call to verify payment status +❌ BUG: Just loads user data from cache/API (still shows pending) + ↓ +User sees: "Complete Your Subscription" (same pending view) + ↓ +User confused: "I just paid!" + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 5: WEBHOOK (5-30 SEC LATER) │ +└────────────────────────────────────────────────────────────────────┘ + +Stripe sends webhook → POST /v1/billing/stripe/webhook/ + └─> Event: checkout.session.completed + +BACKEND ACTIONS (stripe_views.py - _handle_checkout_completed): + ✅ 1. Idempotency check - session_id in Payment.metadata + ✅ 2. Get account from metadata + ✅ 3. Retrieve Stripe subscription details + ✅ 4. Create/Update Subscription record: + - status = 'active' + - stripe_subscription_id = 'sub_xxx' + - current_period_start, current_period_end + ✅ 5. Get or create Invoice (prevents duplicates) + ✅ 6. Mark Invoice as PAID: + - status = 'paid' + - paid_at = now + ✅ 7. Create Payment record: + - status = 'succeeded' + - approved_at = now + - stripe_payment_intent_id + - metadata: { checkout_session_id, subscription_id } + ✅ 8. Create/Update AccountPaymentMethod: + - type = 'stripe' + - is_verified = True + - is_default = True + ✅ 9. Add credits: + - CreditService.add_credits(account, plan.included_credits) + ✅ 10. Update Account: + - status = 'active' + - plan = plan + ❌ 11. NO email sent + ❌ 12. NO real-time notification to frontend + +Returns 200 to Stripe + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 6: USER EXPERIENCE │ +└────────────────────────────────────────────────────────────────────┘ + +User still on /account/plans + ↓ +Page shows pending payment (cached data) + ↓ +❌ Page doesn't auto-refresh +❌ No websocket notification + ↓ +User must MANUALLY refresh page + ↓ +On refresh: loadBillingData() fetches new status + ↓ +NOW shows: Active plan, credits added + ↓ +✅ Finally works after manual refresh + + +┌────────────────────────────────────────────────────────────────────┐ +│ CURRENT BUGS SUMMARY │ +└────────────────────────────────────────────────────────────────────┘ + +🔴 CRITICAL BUGS: +1. No payment verification on return URL +2. User sees pending state after successful payment +3. Requires manual refresh to see activation +4. No loading/polling state +5. Poor UX - user thinks payment failed + +✅ WORKING CORRECTLY: +1. Webhook processing +2. Account activation +3. Credit addition +4. Subscription creation +5. Invoice marking as paid +``` + +--- + +### 🟡 PAYPAL FLOW - US/GLOBAL USERS (Subscription) + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 1-2: SAME AS STRIPE │ +└────────────────────────────────────────────────────────────────────┘ + +(User signup, account creation, redirect to /account/plans) + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 3: PAYPAL ORDER CREATION │ +└────────────────────────────────────────────────────────────────────┘ + +User on /account/plans (PendingPaymentView) + ↓ +Selects PayPal + ↓ +Clicks "Pay with PayPal" + ↓ + +POST /v1/billing/paypal/subscribe/ + └─> Body: { + plan_slug: 'starter', + payment_method: 'paypal', + return_url: 'http://localhost:5173/account/plans?paypal=success', + cancel_url: 'http://localhost:5173/account/plans?paypal=cancel' + } + +BACKEND ACTIONS (paypal_views.py - PayPalSubscribeView): + 1. Get plan, account + 2. Create PayPal Order: + - intent: 'CAPTURE' + - purchase_units: [{ + amount: { currency_code: 'USD', value: '29.00' }, + custom_id: account.id, + description: plan.name + }] + - application_context: { + return_url: return_url, + cancel_url: cancel_url + } + 3. Extract order_id from response + 4. Extract approve link from response.links + 5. ❌ BUG: order_id NOT stored anywhere for later retrieval + 6. Return { redirect_url: approve_link } + +Frontend: + ❌ BUG: Doesn't store order_id before redirect + Redirects to → approve_link (PayPal page) + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 4: PAYPAL APPROVAL │ +└────────────────────────────────────────────────────────────────────┘ + +User on PayPal page + ↓ +Logs in to PayPal + ↓ +Clicks "Approve" + ↓ +PayPal redirects to → /account/plans?paypal=success&token=EC-xxx&PayerID=yyy + ↓ +❌ BUG: URL has 'token', not 'order_id' + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 5: CAPTURE ATTEMPT (❌ BROKEN) │ +└────────────────────────────────────────────────────────────────────┘ + +Browser lands on /account/plans?paypal=success&token=EC-xxx + ↓ +PlansAndBillingPage.tsx detects ?paypal=success + ↓ +Tries to extract order_id from URL params + ↓ +❌ BUG: URL has 'token=EC-xxx', not 'order_id' +❌ BUG: No code to convert token → order_id +❌ BUG: No stored order_id from Phase 3 + ↓ +Calls POST /v1/billing/paypal/capture-order/ + └─> Body: { order_id: undefined } // ❌ MISSING + ↓ +Backend returns 400: "order_id required" + ↓ +Frontend shows: "Payment not captured" + ↓ +User stuck with pending payment + + +┌────────────────────────────────────────────────────────────────────┐ +│ WHAT SHOULD HAPPEN (IF WORKING) │ +└────────────────────────────────────────────────────────────────────┘ + +Frontend should: + 1. Store order_id in localStorage before redirect + 2. On return, retrieve order_id from localStorage + 3. Call capture with correct order_id + +OR: + +Backend should: + 1. Accept token parameter + 2. Look up order_id from token via PayPal API + 3. Then proceed with capture + +CAPTURE FLOW (paypal_views.py - PayPalCaptureOrderView): + ✅ 1. Idempotency check - order_id already captured + ✅ 2. Call PayPal API to capture order + ✅ 3. Extract payment type from metadata + ✅ 4. If subscription: + - _process_subscription_payment() + - Create Subscription (status='active') + - Create Invoice + - Mark Invoice paid + - Create Payment record + - Create AccountPaymentMethod (type='paypal') + - Add credits + - Activate account + ✅ 5. Return success + +But currently this never executes due to missing order_id. + + +┌────────────────────────────────────────────────────────────────────┐ +│ CURRENT BUGS SUMMARY │ +└────────────────────────────────────────────────────────────────────┘ + +🔴 CRITICAL BUGS: +1. order_id not persisted before redirect +2. Return URL has 'token', not 'order_id' +3. No token → order_id mapping +4. Capture fails completely +5. Payment left uncaptured on PayPal +6. User sees error and can't complete signup + +❌ COMPLETELY BROKEN - PayPal payments don't work +``` + +--- + +### 🟢 BANK TRANSFER FLOW - PAKISTAN USERS + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 1-2: SAME AS STRIPE │ +└────────────────────────────────────────────────────────────────────┘ + +User signup with country=PK, plan=Starter + ↓ +Account created (status='pending_payment') + ↓ +Invoice created (currency='PKR', amount=8,499.00) + ↓ +Redirect to /account/plans + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 3: BANK TRANSFER FORM │ +└────────────────────────────────────────────────────────────────────┘ + +/account/plans loads + ↓ +Shows PendingPaymentView + ↓ +Payment methods: [Stripe ✅, Bank Transfer ✅] (country=PK) + ↓ +❌ BUG: May also show PayPal if country not passed correctly + ↓ +User selects Bank Transfer + ↓ + +Loads bank details via GET /v1/billing/payment-configs/payment-methods/?country_code=PK&payment_method=bank_transfer + ↓ +Backend returns: + { + bank_name: "HBL", + account_title: "IGNY8 Platform", + account_number: "12345678", + iban: "PK36HABB0012345678901234", + swift_code: "HABBPKKA", + instructions: "..." + } + ↓ +Displays bank details to user + ↓ +User transfers PKR 8,499 to bank account + ↓ +User fills form: + - Transaction Reference: TRX123456 + - Upload proof (receipt image) + - Notes: "Paid from account ending 9876" + ↓ +Clicks "Submit Payment Proof" + ↓ + +POST /v1/billing/payments/manual/ + └─> Body: { + invoice_id: , + payment_method: 'bank_transfer', + amount: 8499.00, + manual_reference: 'TRX123456', + manual_notes: '...', + proof_url: 'https://...' + } + +BACKEND ACTIONS (billing_views.py): + ✅ 1. Get invoice + ✅ 2. Create Payment record: + - status = 'pending' (awaiting admin approval) + - payment_method = 'bank_transfer' + - manual_reference = 'TRX123456' + - manual_proof_url = proof + - approved_at = None + - approved_by = None + ✅ 3. Return success + ❌ 4. NO email to admin + ❌ 5. NO email to user + +Frontend shows: "Payment submitted for approval" + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 4: ADMIN APPROVAL │ +└────────────────────────────────────────────────────────────────────┘ + +Admin logs into Django Admin + ↓ +Navigates to Payments + ↓ +Sees Payment (status='pending', reference='TRX123456') + ↓ +Views proof image + ↓ +Verifies transaction in bank statement + ↓ +Changes status to 'succeeded' + ↓ +Sets approved_by = admin_user + ↓ +Clicks "Save" + ↓ + +BACKEND ACTIONS (admin.py - save_model or signal): + ✅ 1. Mark Invoice as paid + ✅ 2. Update Subscription status = 'active' + ✅ 3. Add credits to account + ✅ 4. Update Account: + - status = 'active' + - payment_method = 'bank_transfer' + ✅ 5. Create AccountPaymentMethod: + - type = 'bank_transfer' + - is_verified = True + - is_default = True + ❌ 6. NO email to user about approval + + +┌────────────────────────────────────────────────────────────────────┐ +│ PHASE 5: USER SEES ACTIVATION │ +└────────────────────────────────────────────────────────────────────┘ + +User refreshes /account/plans + ↓ +loadBillingData() fetches updated status + ↓ +Shows: Active plan, credits available + ↓ +✅ Works correctly after admin approval + + +┌────────────────────────────────────────────────────────────────────┐ +│ CURRENT BUGS SUMMARY │ +└────────────────────────────────────────────────────────────────────┘ + +🟡 MINOR BUGS: +1. No email notification to admin +2. No email to user on approval +3. Currency symbol shows '$' in UI (should show 'Rs') +4. ❌ PayPal may show for PK users if country filtering broken + +✅ WORKING CORRECTLY: +1. Bank details from backend +2. Manual payment submission +3. Admin approval flow +4. Account activation after approval +``` + +--- + +### 💳 CREDIT PURCHASE FLOWS + +#### Stripe Credit Purchase (US/Global) + +``` +User on /account/plans (Active account) + ↓ +Clicks "Buy Credits" + ↓ +Selects package: 1000 credits for $50 + ↓ +Clicks "Buy with Stripe" + ↓ + +POST /v1/billing/stripe/purchase-credits/ + └─> Body: { + credit_package_id: , + payment_method: 'stripe', + return_url: '...', + cancel_url: '...' + } + +BACKEND: + 1. Create Stripe Checkout Session (mode='payment') + 2. metadata: { credit_package_id, credit_amount, type: 'credits' } + 3. Return redirect_url + +User pays on Stripe → redirects back + ↓ +❌ SAME BUG: No payment verification on return + ↓ +Webhook fires → _add_purchased_credits() + ↓ +✅ Creates Invoice +✅ Marks paid +✅ Creates Payment +✅ Adds credits + ↓ +User must manual refresh to see new credits +``` + +#### PayPal Credit Purchase (US/Global) + +``` +User clicks "Buy with PayPal" + ↓ +POST /v1/billing/paypal/purchase-credits/ + ↓ +Creates PayPal Order + ↓ +❌ SAME BUGS: order_id not stored, token not mapped + ↓ +User approves on PayPal + ↓ +Return URL has token + ↓ +❌ Capture fails - no order_id + ↓ +Payment not completed +``` + +#### Bank Transfer Credit Purchase (PK) + +``` +User selects amount → bank transfer form + ↓ +Submits proof → creates Payment (pending) + ↓ +Admin approves → adds credits + ↓ +✅ Works correctly +``` + +--- + +## 🔄 RECURRING PAYMENT FLOWS + +### Stripe Subscription Renewal + +``` +30 days after activation + ↓ +Stripe auto-charges customer + ↓ +Stripe sends webhook: invoice.paid + ↓ + +BACKEND (_handle_invoice_paid): + ✅ 1. Skip if billing_reason = 'subscription_create' + ✅ 2. Get Subscription from stripe_subscription_id + ✅ 3. Add renewal credits: + CreditService.add_credits(account, plan.included_credits) + ✅ 4. Update subscription period dates + ❌ 5. NO email sent + +✅ WORKS CORRECTLY +``` + +### Stripe Payment Failure + +``` +Renewal charge fails (card declined) + ↓ +Stripe sends webhook: invoice.payment_failed + ↓ + +BACKEND (_handle_invoice_payment_failed): + ✅ 1. Update Subscription status = 'past_due' + ✅ 2. Log failure + ❌ 3. TODO: Send email (not implemented) + +⚠️ PARTIAL - Updates status but no notification +``` + +### PayPal Recurring (If using PayPal Subscriptions) + +``` +PayPal sends: BILLING.SUBSCRIPTION.ACTIVATED + ↓ +BACKEND (_handle_subscription_activated): + ✅ Creates/updates Subscription + ✅ Adds credits + ✅ Activates account + +PayPal sends: BILLING.SUBSCRIPTION.PAYMENT.FAILED + ↓ +BACKEND (_handle_subscription_payment_failed): + ✅ Updates status = 'past_due' + ❌ TODO: Send email + +⚠️ PARTIAL - Works but no notifications +``` + +--- + +## 🎯 ROOT CAUSES OF ALL ISSUES + +### 1. Country Filtering Not Applied +**One line fix in PlansAndBillingPage.tsx:** +```tsx +// Line ~251 - ADD country parameter: +const billingCountry = user?.account?.billing_country || 'US'; +const gateways = await getAvailablePaymentGateways(billingCountry); +``` + +### 2. No Payment Return Verification +**Need new backend endpoints:** +```python +# stripe_views.py +class VerifyStripeReturnView(APIView): + def get(self, request): + session_id = request.query_params.get('session_id') + # Retrieve session from Stripe + # Check payment status + # Return account/subscription status + +# paypal_views.py +class VerifyPayPalReturnView(APIView): + def get(self, request): + token = request.query_params.get('token') + # Map token to order_id + # Check order status with PayPal + # Return account status +``` + +**Frontend needs to call these on return:** +```tsx +useEffect(() => { + const params = new URLSearchParams(location.search); + + if (params.get('success') && params.get('session_id')) { + verifyStripePayment(params.get('session_id')); + } + + if (params.get('paypal') === 'success' && params.get('token')) { + verifyPayPalPayment(params.get('token')); + } +}, [location.search]); +``` + +### 3. PayPal Order ID Not Persisted +**Fix in frontend before redirect:** +```tsx +// Before redirecting to PayPal: +const { order_id, redirect_url } = await createPayPalOrder(); +localStorage.setItem('paypal_order_id', order_id); +window.location.href = redirect_url; + +// On return: +const order_id = localStorage.getItem('paypal_order_id'); +await capturePayPalOrder(order_id); +localStorage.removeItem('paypal_order_id'); +``` + +### 4. Currency Display Hardcoded +**Fix dynamic currency:** +```tsx +const getCurrencySymbol = (country: string, method: string) => { + if (method === 'bank_transfer' && country === 'PK') return 'Rs'; + return '$'; +}; + +const getCurrencyCode = (country: string, method: string) => { + if (method === 'bank_transfer' && country === 'PK') return 'PKR'; + return 'USD'; +}; +``` + +### 5. Status Logic Inconsistent +**Fix hasActivePlan:** +```tsx +const hasActivePlan = user?.account?.status === 'active' + && user?.account?.plan?.slug !== 'free' + && !hasPendingInvoice; + +const canManageBilling = user?.account?.payment_method === 'stripe' + && user?.account?.stripe_customer_id + && hasActivePlan; +``` + +--- + +## 📋 COMPLETE ISSUE CHECKLIST + +| # | Issue | Severity | Status | Fix Required | +|---|-------|----------|--------|--------------| +| 1 | Country not passed to payment gateway selector | 🔴 CRITICAL | Open | Add country param | +| 2 | Stripe return doesn't verify payment | 🔴 CRITICAL | Open | Add verification endpoint | +| 3 | PayPal order_id not persisted | 🔴 CRITICAL | Open | Store in localStorage | +| 4 | PayPal token not mapped to order_id | 🔴 CRITICAL | Open | Backend mapping or localStorage | +| 5 | Invoice remains pending after Stripe success | 🔴 CRITICAL | Open | Add return verification | +| 6 | PayPal capture fails on return | 🔴 CRITICAL | Open | Fix order_id persistence | +| 7 | Currency hardcoded to USD | 🟡 HIGH | Open | Dynamic currency by country/method | +| 8 | Manage Billing shows for wrong users | 🟡 HIGH | Open | Check actual payment_method | +| 9 | hasActivePlan logic incorrect | 🟡 HIGH | Open | Check account status properly | +| 10 | No real-time status update after payment | 🟡 HIGH | Open | Polling or websocket | +| 11 | Backend payment methods ignore country | 🟢 MEDIUM | Open | Filter by country_code | +| 12 | No email notifications | 🟢 MEDIUM | Deferred | Add email service | +| 13 | No webhook audit trail | 🟢 MEDIUM | Open | Store in WebhookEvent model | + +--- + +## 🚀 PRIORITY FIX ORDER + +### IMMEDIATE (Do First): +1. Fix country filtering in gateway selection +2. Add Stripe return verification endpoint +3. Fix PayPal order_id persistence +4. Test end-to-end payment flows + +### HIGH PRIORITY (Do Next): +5. Dynamic currency display +6. Fix hasActivePlan logic +7. Fix Manage Billing button logic +8. Add webhook event storage + +### MEDIUM PRIORITY (Do After): +9. Real-time status updates (polling/websocket) +10. Email notifications +11. Admin notification for manual payments + +--- + +*End of Critical Issues Analysis* 4. **Country handling simplified** - Dropdown instead of detection API 5. **Code cleaned up** - No dead routes, no hardcoded values 6. **All payment methods work** - Stripe, PayPal, Bank Transfer diff --git a/frontend/src/components/billing/BankTransferForm.tsx b/frontend/src/components/billing/BankTransferForm.tsx index 9af3ff1f..e9453101 100644 --- a/frontend/src/components/billing/BankTransferForm.tsx +++ b/frontend/src/components/billing/BankTransferForm.tsx @@ -281,7 +281,16 @@ export default function BankTransferForm({
Amount to Transfer - {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); + })()}
diff --git a/frontend/src/components/billing/PendingPaymentView.tsx b/frontend/src/components/billing/PendingPaymentView.tsx index c8cebfe2..7e78f2ec 100644 --- a/frontend/src/components/billing/PendingPaymentView.tsx +++ b/frontend/src/components/billing/PendingPaymentView.tsx @@ -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 }) => ( ); +// Currency symbol helper +const getCurrencySymbol = (currency: string): string => { + const symbols: Record = { + 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('stripe'); const [loading, setLoading] = useState(false); const [gatewaysLoading, setGatewaysLoading] = useState(true); const [paymentOptions, setPaymentOptions] = useState([]); 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: , - }); - } - } else { - if (gateways.paypal) { - options.push({ - id: 'paypal', - type: 'paypal', - name: 'PayPal', - description: 'Pay with your PayPal account', - icon: , - }); - } + // 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: , + }); + } + + // 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: , + }); } 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 ( +
+
+ {/* Header with Awaiting Badge */} +
+
+ +
+
+ + Awaiting Approval + +
+

+ Payment Submitted! +

+

+ Your bank transfer for {planName} is being verified +

+
+ + {/* Status Card */} + +
+
+ +
+
+

Bank Transfer

+

Manual verification required

+
+
+ +
+
+ {planName} Plan + ${planPrice} USD +
+
+ Amount Transferred (PKR) + PKR {pkrEquivalent.toLocaleString()} +
+
+
+ + {/* Info Pointers */} + +

+ + What happens next? +

+
+
+
1
+
+

Verification in Progress

+

Our team is reviewing your payment

+
+
+
+
2
+
+

Email Confirmation

+

You'll receive an email once approved

+
+
+
+
3
+
+

Account Activated

+

Your subscription will be activated automatically

+
+
+
+
+ + {/* Time Estimate Badge */} +
+ +

+ Expected approval time: Within 24 hours (usually faster) +

+
+ + {/* Check Status Button */} + +

+ Status is checked automatically every 30 seconds +

+ + {/* Disabled Payment Options Notice */} +
+
+ + Payment options disabled +
+

+ Other payment methods are disabled while your bank transfer is being verified. +

+
+
+
+ ); + } + return (
@@ -222,13 +418,24 @@ export default function PendingPaymentView({
{planName} Plan (Monthly) - ${planPrice} + ${planPrice} USD
+ {showPKREquivalent && ( +
+ Bank Transfer Amount (PKR) + PKR {pkrEquivalent.toLocaleString()} +
+ )}
Total - ${planPrice} +
+ ${planPrice} USD + {showPKREquivalent && ( +
≈ PKR {pkrEquivalent.toLocaleString()}
+ )} +
@@ -320,16 +527,16 @@ export default function PendingPaymentView({ Processing... ) : selectedGateway === 'manual' ? ( - 'Continue to Bank Transfer' + 'Continue to Bank Transfer Details' ) : ( - `Pay $${planPrice} Now` + `Pay $${planPrice} USD Now` )} {/* Info text */}

{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' }

diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index 275f154d..2e88bac9 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -166,7 +166,10 @@ const LayoutContent: React.FC = () => { > {/* Pending Payment Banner - Shows when account status is 'pending_payment' */} - + {/* Hidden on /account/plans since PendingPaymentView handles it there */} + {!window.location.pathname.startsWith('/account/plans') && ( + + )}
diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index bb4cf4e1..848e074e 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -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(''); @@ -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( +
+ {/* Glass-like backdrop - same as Modal component */} +
+ + {/* Content card - Modal style */} +
+ {/* Main Loading Spinner */} +
+
+
+
+
+ + {/* Message */} +

+ {paymentProcessing.message} +

+

+ Please don't close this page +

+ + {/* Stage Badge */} +
+ + + {config.label} + +
+
+
, + document.body + ); + } + + // Show loading spinner until initial data is loaded + // This prevents the flash of billing dashboard before PendingPaymentView + if (!initialDataLoaded) { + return ( +
+
+
+

Loading billing information...

+
+
+ ); + } + // 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 ( { // Refresh user and billing data const { refreshUser } = useAuthStore.getState(); @@ -482,23 +834,23 @@ export default function PlansAndBillingPage() { {/* SECTION 1: Current Plan Hero */}
{/* Main Plan Card */} - +
-

+

{currentPlan?.name || 'No Plan'}

{hasActivePlan ? 'Active' : 'Inactive'}
-

+

{currentPlan?.description || 'Select a plan to unlock features'}

- {availableGateways.stripe && hasActivePlan && ( + {canManageBilling && (
- {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) => (
{/* Main Credit Card */} - +
-

Credit Balance

-

Your available credits for AI operations

+

Credit Balance

+

Your available credits for AI operations