From ad75fa031eb50602dc93ee29f6825b26df00061b Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Wed, 7 Jan 2026 13:02:53 +0000 Subject: [PATCH] payment gateways and plans billing and signup pages refactored --- backend/igny8_core/auth/urls.py | 124 +- backend/igny8_core/business/billing/models.py | 128 ++ .../business/billing/views/paypal_views.py | 38 +- .../business/billing/views/refund_views.py | 38 +- .../business/billing/views/stripe_views.py | 9 + ...k_event_and_manual_reference_constraint.py | 63 + .../PAYMENT-SYSTEM-ARCHITECTURE.md | 1120 ++++++++++++++ .../PAYMENT-SYSTEM-AUDIT-REPORT.md | 959 ++++++++++++ .../PAYMENT-SYSTEM-REFACTOR-PLAN.md | 1350 +++++++++++++++++ frontend/src/App.tsx | 6 +- .../src/components/auth/SignUpFormUnified.tsx | 350 ++--- .../components/billing/BankTransferForm.tsx | 355 +++++ .../components/billing/PendingPaymentView.tsx | 339 +++++ frontend/src/pages/AuthPages/SignUp.tsx | 46 +- frontend/src/pages/AuthPages/SignUpPK.tsx | 116 -- .../src/pages/account/PlansAndBillingPage.tsx | 33 + frontend/src/store/authStore.ts | 13 +- 17 files changed, 4587 insertions(+), 500 deletions(-) create mode 100644 backend/igny8_core/modules/billing/migrations/0029_add_webhook_event_and_manual_reference_constraint.py create mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md create mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md create mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md create mode 100644 frontend/src/components/billing/BankTransferForm.tsx create mode 100644 frontend/src/components/billing/PendingPaymentView.tsx delete mode 100644 frontend/src/pages/AuthPages/SignUpPK.tsx diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index e16e65c3..705a0f3c 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -121,55 +121,9 @@ class RegisterView(APIView): } } - # For Stripe payment method with paid plan, create checkout session - payment_method = request.data.get('payment_method', '') - import logging - logger = logging.getLogger(__name__) - logger.info(f"Registration: payment_method={payment_method}, account_status={account.status if account else 'no account'}") - - if account and account.status == 'pending_payment' and payment_method == 'stripe': - try: - from igny8_core.business.billing.services.stripe_service import StripeService - stripe_service = StripeService() - logger.info(f"Creating Stripe checkout for account {account.id}, plan {account.plan.name}") - checkout_data = stripe_service.create_checkout_session( - account=account, - plan=account.plan, - ) - logger.info(f"Stripe checkout created: {checkout_data}") - response_data['checkout_url'] = checkout_data.get('checkout_url') - response_data['checkout_session_id'] = checkout_data.get('session_id') - except Exception as e: - logger.error(f"Failed to create Stripe checkout session: {e}", exc_info=True) - # Don't fail registration, just log the error - # User can still complete payment from the plans page - - # For PayPal payment method with paid plan, create PayPal order - elif account and account.status == 'pending_payment' and payment_method == 'paypal': - try: - from django.conf import settings - from igny8_core.business.billing.services.paypal_service import PayPalService - paypal_service = PayPalService() - frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') - - logger.info(f"Creating PayPal order for account {account.id}, amount {account.plan.price}") - order = paypal_service.create_order( - account=account, - amount=float(account.plan.price), - description=f'{account.plan.name} Plan Subscription', - return_url=f'{frontend_url}/account/plans?paypal=success&plan_id={account.plan.id}', - cancel_url=f'{frontend_url}/account/plans?paypal=cancel', - metadata={ - 'plan_id': str(account.plan.id), - 'type': 'subscription', - } - ) - logger.info(f"PayPal order created: {order}") - response_data['checkout_url'] = order.get('approval_url') - response_data['paypal_order_id'] = order.get('order_id') - except Exception as e: - logger.error(f"Failed to create PayPal order: {e}", exc_info=True) - # Don't fail registration, just log the error + # NOTE: Payment checkout is NO LONGER created at registration + # User will complete payment on /account/plans after signup + # This simplifies the signup flow and consolidates all payment handling return success_response( data=response_data, @@ -432,6 +386,77 @@ class RefreshTokenView(APIView): ) +@extend_schema( + tags=['Authentication'], + summary='Get Country List', + description='Returns list of countries for registration country selection' +) +class CountryListView(APIView): + """Returns list of countries for signup dropdown""" + permission_classes = [permissions.AllowAny] # Public endpoint + + def get(self, request): + """Get list of countries with codes and names""" + # Comprehensive list of countries for billing purposes + countries = [ + {'code': 'US', 'name': 'United States'}, + {'code': 'GB', 'name': 'United Kingdom'}, + {'code': 'CA', 'name': 'Canada'}, + {'code': 'AU', 'name': 'Australia'}, + {'code': 'DE', 'name': 'Germany'}, + {'code': 'FR', 'name': 'France'}, + {'code': 'ES', 'name': 'Spain'}, + {'code': 'IT', 'name': 'Italy'}, + {'code': 'NL', 'name': 'Netherlands'}, + {'code': 'BE', 'name': 'Belgium'}, + {'code': 'CH', 'name': 'Switzerland'}, + {'code': 'AT', 'name': 'Austria'}, + {'code': 'SE', 'name': 'Sweden'}, + {'code': 'NO', 'name': 'Norway'}, + {'code': 'DK', 'name': 'Denmark'}, + {'code': 'FI', 'name': 'Finland'}, + {'code': 'IE', 'name': 'Ireland'}, + {'code': 'PT', 'name': 'Portugal'}, + {'code': 'PL', 'name': 'Poland'}, + {'code': 'CZ', 'name': 'Czech Republic'}, + {'code': 'NZ', 'name': 'New Zealand'}, + {'code': 'SG', 'name': 'Singapore'}, + {'code': 'HK', 'name': 'Hong Kong'}, + {'code': 'JP', 'name': 'Japan'}, + {'code': 'KR', 'name': 'South Korea'}, + {'code': 'IN', 'name': 'India'}, + {'code': 'PK', 'name': 'Pakistan'}, + {'code': 'BD', 'name': 'Bangladesh'}, + {'code': 'AE', 'name': 'United Arab Emirates'}, + {'code': 'SA', 'name': 'Saudi Arabia'}, + {'code': 'ZA', 'name': 'South Africa'}, + {'code': 'NG', 'name': 'Nigeria'}, + {'code': 'EG', 'name': 'Egypt'}, + {'code': 'KE', 'name': 'Kenya'}, + {'code': 'BR', 'name': 'Brazil'}, + {'code': 'MX', 'name': 'Mexico'}, + {'code': 'AR', 'name': 'Argentina'}, + {'code': 'CL', 'name': 'Chile'}, + {'code': 'CO', 'name': 'Colombia'}, + {'code': 'PE', 'name': 'Peru'}, + {'code': 'MY', 'name': 'Malaysia'}, + {'code': 'TH', 'name': 'Thailand'}, + {'code': 'VN', 'name': 'Vietnam'}, + {'code': 'PH', 'name': 'Philippines'}, + {'code': 'ID', 'name': 'Indonesia'}, + {'code': 'TR', 'name': 'Turkey'}, + {'code': 'RU', 'name': 'Russia'}, + {'code': 'UA', 'name': 'Ukraine'}, + {'code': 'RO', 'name': 'Romania'}, + {'code': 'GR', 'name': 'Greece'}, + {'code': 'IL', 'name': 'Israel'}, + {'code': 'TW', 'name': 'Taiwan'}, + ] + # Sort alphabetically by name + countries.sort(key=lambda x: x['name']) + return Response({'countries': countries}) + + @extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint class MeView(APIView): """Get current user information.""" @@ -456,5 +481,6 @@ urlpatterns = [ path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'), path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'), path('me/', MeView.as_view(), name='auth-me'), + path('countries/', CountryListView.as_view(), name='auth-countries'), ] diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 385c9758..36e9706e 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -487,6 +487,7 @@ class Payment(AccountBaseModel): manual_reference = models.CharField( max_length=255, blank=True, + null=True, help_text="Bank transfer reference, wallet transaction ID, etc." ) manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments") @@ -526,9 +527,24 @@ class Payment(AccountBaseModel): models.Index(fields=['account', 'payment_method']), models.Index(fields=['invoice', 'status']), ] + constraints = [ + # Ensure manual_reference is unique when not null/empty + # This prevents duplicate bank transfer references + models.UniqueConstraint( + fields=['manual_reference'], + name='unique_manual_reference_when_not_null', + condition=models.Q(manual_reference__isnull=False) & ~models.Q(manual_reference='') + ), + ] def __str__(self): return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}" + + def save(self, *args, **kwargs): + """Normalize empty manual_reference to NULL for proper uniqueness handling""" + if self.manual_reference == '': + self.manual_reference = None + super().save(*args, **kwargs) class CreditPackage(models.Model): @@ -854,3 +870,115 @@ class AIModelConfig(models.Model): model_type='image', is_active=True ).order_by('quality_tier', 'model_name') + + +class WebhookEvent(models.Model): + """ + Store all incoming webhook events for audit and replay capability. + + This model provides: + - Audit trail of all webhook events + - Idempotency verification (via event_id) + - Ability to replay failed events + - Debugging and monitoring + """ + PROVIDER_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ] + + # Unique identifier from the payment provider + event_id = models.CharField( + max_length=255, + unique=True, + db_index=True, + help_text="Unique event ID from the payment provider" + ) + + # Payment provider + provider = models.CharField( + max_length=20, + choices=PROVIDER_CHOICES, + db_index=True, + help_text="Payment provider (stripe or paypal)" + ) + + # Event type (e.g., 'checkout.session.completed', 'PAYMENT.CAPTURE.COMPLETED') + event_type = models.CharField( + max_length=100, + db_index=True, + help_text="Event type from the provider" + ) + + # Full payload for debugging and replay + payload = models.JSONField( + help_text="Full webhook payload" + ) + + # Processing status + processed = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this event has been successfully processed" + ) + processed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the event was processed" + ) + + # Error tracking + error_message = models.TextField( + blank=True, + help_text="Error message if processing failed" + ) + retry_count = models.IntegerField( + default=0, + help_text="Number of processing attempts" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + app_label = 'billing' + db_table = 'igny8_webhook_events' + verbose_name = 'Webhook Event' + verbose_name_plural = 'Webhook Events' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['provider', 'event_type']), + models.Index(fields=['processed', 'created_at']), + models.Index(fields=['provider', 'processed']), + ] + + def __str__(self): + return f"{self.provider}:{self.event_type} - {self.event_id[:20]}..." + + @classmethod + def record_event(cls, event_id: str, provider: str, event_type: str, payload: dict): + """ + Record a webhook event. Returns (event, created) tuple. + If the event already exists, returns the existing event. + """ + return cls.objects.get_or_create( + event_id=event_id, + defaults={ + 'provider': provider, + 'event_type': event_type, + 'payload': payload, + } + ) + + def mark_processed(self): + """Mark the event as successfully processed""" + from django.utils import timezone + self.processed = True + self.processed_at = timezone.now() + self.save(update_fields=['processed', 'processed_at']) + + def mark_failed(self, error_message: str): + """Mark the event as failed with error message""" + self.error_message = error_message + self.retry_count += 1 + self.save(update_fields=['error_message', 'retry_count']) diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py index 9d9e17b0..2e03bf11 100644 --- a/backend/igny8_core/business/billing/views/paypal_views.py +++ b/backend/igny8_core/business/billing/views/paypal_views.py @@ -19,6 +19,7 @@ Endpoints: """ import json import logging +from decimal import Decimal from django.conf import settings from django.utils import timezone from django.db import transaction @@ -293,6 +294,23 @@ class PayPalCaptureOrderView(APIView): request=request ) + # IDEMPOTENCY CHECK - Prevent duplicate captures + existing = Payment.objects.filter( + paypal_order_id=order_id, + status='succeeded' + ).first() + if existing: + logger.info(f"PayPal order {order_id} already captured as payment {existing.id}") + return success_response( + data={ + 'status': 'already_captured', + 'payment_id': str(existing.id), + 'message': 'This order has already been captured' + }, + message='Order already captured', + request=request + ) + try: service = PayPalService() @@ -501,9 +519,8 @@ def paypal_webhook(request): is_valid = service.verify_webhook_signature(headers, body) if not is_valid: - logger.warning("PayPal webhook signature verification failed") - # Optionally reject invalid signatures - # return Response({'error': 'Invalid signature'}, status=400) + logger.error("PayPal webhook signature verification failed") + return Response({'error': 'Invalid signature'}, status=status.HTTP_400_BAD_REQUEST) except PayPalConfigurationError: logger.warning("PayPal not configured for webhook verification") @@ -550,6 +567,21 @@ def _process_credit_purchase(account, package_id: str, capture_result: dict) -> logger.error(f"Credit package {package_id} not found for PayPal capture") return {'error': 'Package not found'} + # AMOUNT VALIDATION - Prevent price manipulation + captured_amount = Decimal(str(capture_result.get('amount', '0'))) + expected_amount = Decimal(str(package.price)) + + if abs(captured_amount - expected_amount) > Decimal('0.01'): + logger.error( + f"PayPal amount mismatch for package {package_id}: " + f"captured={captured_amount}, expected={expected_amount}" + ) + return { + 'error': 'Payment amount does not match expected amount', + 'captured': str(captured_amount), + 'expected': str(expected_amount) + } + with transaction.atomic(): # Create invoice invoice = InvoiceService.create_credit_package_invoice( diff --git a/backend/igny8_core/business/billing/views/refund_views.py b/backend/igny8_core/business/billing/views/refund_views.py index c34dd66a..8cfec887 100644 --- a/backend/igny8_core/business/billing/views/refund_views.py +++ b/backend/igny8_core/business/billing/views/refund_views.py @@ -160,20 +160,18 @@ def initiate_refund(request, payment_id): def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bool: """Process Stripe refund""" try: - import stripe - from igny8_core.business.billing.utils.payment_gateways import get_stripe_client + from igny8_core.business.billing.services.stripe_service import StripeService - stripe_client = get_stripe_client() + stripe_service = StripeService() - refund = stripe_client.Refund.create( - payment_intent=payment.stripe_payment_intent_id, + refund = stripe_service.create_refund( + payment_intent_id=payment.stripe_payment_intent_id, amount=int(amount * 100), # Convert to cents reason='requested_by_customer', - metadata={'reason': reason} ) - payment.metadata['stripe_refund_id'] = refund.id - return refund.status == 'succeeded' + payment.metadata['stripe_refund_id'] = refund.get('id') + return refund.get('status') == 'succeeded' except Exception as e: logger.exception(f"Stripe refund failed for payment {payment.id}: {str(e)}") @@ -183,25 +181,19 @@ def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bo def _process_paypal_refund(payment: Payment, amount: Decimal, reason: str) -> bool: """Process PayPal refund""" try: - from igny8_core.business.billing.utils.payment_gateways import get_paypal_client + from igny8_core.business.billing.services.paypal_service import PayPalService - paypal_client = get_paypal_client() + paypal_service = PayPalService() - refund_request = { - 'amount': { - 'value': str(amount), - 'currency_code': payment.currency - }, - 'note_to_payer': reason - } - - refund = paypal_client.payments.captures.refund( - payment.paypal_capture_id, - refund_request + refund = paypal_service.refund_capture( + capture_id=payment.paypal_capture_id, + amount=float(amount), + currency=payment.currency, + note=reason, ) - payment.metadata['paypal_refund_id'] = refund.id - return refund.status == 'COMPLETED' + payment.metadata['paypal_refund_id'] = refund.get('id') + return refund.get('status') == 'COMPLETED' except Exception as e: logger.exception(f"PayPal refund failed for payment {payment.id}: {str(e)}") diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py index 7c0d73e7..e565521b 100644 --- a/backend/igny8_core/business/billing/views/stripe_views.py +++ b/backend/igny8_core/business/billing/views/stripe_views.py @@ -358,6 +358,15 @@ def _handle_checkout_completed(session: dict): Processes both subscription and one-time credit purchases. """ + session_id = session.get('id') + + # IDEMPOTENCY CHECK - Prevent processing duplicate webhooks + if Payment.objects.filter( + metadata__stripe_checkout_session_id=session_id + ).exists(): + logger.info(f"Webhook already processed for session {session_id}") + return + metadata = session.get('metadata', {}) account_id = metadata.get('account_id') payment_type = metadata.get('type', '') diff --git a/backend/igny8_core/modules/billing/migrations/0029_add_webhook_event_and_manual_reference_constraint.py b/backend/igny8_core/modules/billing/migrations/0029_add_webhook_event_and_manual_reference_constraint.py new file mode 100644 index 00000000..d5b4a147 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0029_add_webhook_event_and_manual_reference_constraint.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.9 on 2026-01-07 12:26 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0028_cleanup_payment_method_config'), + ('igny8_core_auth', '0020_fix_historical_account'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WebhookEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_id', models.CharField(db_index=True, help_text='Unique event ID from the payment provider', max_length=255, unique=True)), + ('provider', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal')], db_index=True, help_text='Payment provider (stripe or paypal)', max_length=20)), + ('event_type', models.CharField(db_index=True, help_text='Event type from the provider', max_length=100)), + ('payload', models.JSONField(help_text='Full webhook payload')), + ('processed', models.BooleanField(db_index=True, default=False, help_text='Whether this event has been successfully processed')), + ('processed_at', models.DateTimeField(blank=True, help_text='When the event was processed', null=True)), + ('error_message', models.TextField(blank=True, help_text='Error message if processing failed')), + ('retry_count', models.IntegerField(default=0, help_text='Number of processing attempts')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Webhook Event', + 'verbose_name_plural': 'Webhook Events', + 'db_table': 'igny8_webhook_events', + 'ordering': ['-created_at'], + }, + ), + migrations.AlterField( + model_name='historicalpayment', + name='manual_reference', + field=models.CharField(blank=True, help_text='Bank transfer reference, wallet transaction ID, etc.', max_length=255, null=True), + ), + migrations.AlterField( + model_name='payment', + name='manual_reference', + field=models.CharField(blank=True, help_text='Bank transfer reference, wallet transaction ID, etc.', max_length=255, null=True), + ), + migrations.AddConstraint( + model_name='payment', + constraint=models.UniqueConstraint(condition=models.Q(('manual_reference__isnull', False), models.Q(('manual_reference', ''), _negated=True)), fields=('manual_reference',), name='unique_manual_reference_when_not_null'), + ), + migrations.AddIndex( + model_name='webhookevent', + index=models.Index(fields=['provider', 'event_type'], name='igny8_webho_provide_ee8a78_idx'), + ), + migrations.AddIndex( + model_name='webhookevent', + index=models.Index(fields=['processed', 'created_at'], name='igny8_webho_process_88c670_idx'), + ), + migrations.AddIndex( + model_name='webhookevent', + index=models.Index(fields=['provider', 'processed'], name='igny8_webho_provide_df293b_idx'), + ), + ] diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md new file mode 100644 index 00000000..b1a755d8 --- /dev/null +++ b/docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md @@ -0,0 +1,1120 @@ +# Complete Payment System Architecture + +> **Version:** 1.0 +> **Last Updated:** January 7, 2026 +> **Status:** Complete Documentation + +This document provides comprehensive documentation of the IGNY8 payment system architecture, including all entry points, services, models, and flows. + +--- + +## Table of Contents + +1. [System Overview](#system-overview) +2. [Payment Entry Points (Frontend)](#payment-entry-points-frontend) +3. [Frontend Payment Services](#frontend-payment-services) +4. [Backend Endpoints](#backend-endpoints) +5. [Backend Services](#backend-services) +6. [Models](#models) +7. [Payment Method Configuration](#payment-method-configuration) +8. [Payment Flows](#payment-flows) +9. [Webhooks](#webhooks) + +--- + +## System Overview + +### Supported Payment Methods + +| Method | Type | Regions | Use Cases | +|--------|------|---------|-----------| +| **Stripe** | Credit/Debit Card | Global | Subscriptions, Credit packages | +| **PayPal** | PayPal account | Global (except PK) | Subscriptions, Credit packages | +| **Bank Transfer** | Manual | Pakistan (PK) | Subscriptions, Credit packages | +| **Local Wallet** | Manual | Configurable | Credit packages | + +### Payment Method Selection Logic + +- **Global Users**: Stripe (Card) + PayPal +- **Pakistan Users**: Stripe (Card) + Bank Transfer (NO PayPal) + +--- + +## Payment Entry Points (Frontend) + +### 1. SignUpFormUnified.tsx + +**File:** [frontend/src/components/auth/SignUpFormUnified.tsx](../../../frontend/src/components/auth/SignUpFormUnified.tsx) + +**Purpose:** Handles payment method selection and checkout redirect during user registration. + +#### Key Features (Lines 1-770) + +``` +- Payment Method Loading: Lines 105-195 +- Form Submission with Payment: Lines 213-344 +- Payment Gateway Redirect Logic: Lines 315-340 +``` + +#### Payment Flow: + +1. **Loads available payment methods** from `/v1/billing/payment-configs/payment-methods/` (Lines 110-195) +2. **Determines available options** based on country: + - Pakistan (`/signup/pk`): Stripe + Bank Transfer + - Global (`/signup`): Stripe + PayPal +3. **On form submit** (Lines 213-344): + - Calls `register()` from authStore + - Backend returns `checkout_url` for Stripe/PayPal + - Redirects to payment gateway OR plans page (bank transfer) + +#### Data Passed to Backend: + +```typescript +{ + email, password, username, first_name, last_name, + account_name, + plan_slug: selectedPlan.slug, + payment_method: selectedPaymentMethod, // 'stripe' | 'paypal' | 'bank_transfer' + billing_email, + billing_country +} +``` + +--- + +### 2. PlansAndBillingPage.tsx + +**File:** [frontend/src/pages/account/PlansAndBillingPage.tsx](../../../frontend/src/pages/account/PlansAndBillingPage.tsx) + +**Purpose:** Comprehensive billing dashboard for subscription management, credit purchases, and payment history. + +#### Key Features (Lines 1-1197) + +``` +- Payment Gateway Return Handler: Lines 100-155 +- Plan Selection (handleSelectPlan): Lines 283-310 +- Credit Purchase (handlePurchaseCredits): Lines 330-352 +- Billing Portal (handleManageSubscription): Lines 354-361 +- Invoice Download: Lines 363-376 +``` + +#### Payment Flows: + +**1. Plan Upgrade (handleSelectPlan - Lines 283-310):** +```typescript +// For Stripe/PayPal: +const { redirect_url } = await subscribeToPlan(planId.toString(), selectedGateway); +window.location.href = redirect_url; + +// For Manual/Bank Transfer: +await createSubscription({ plan_id: planId, payment_method: selectedPaymentMethod }); +``` + +**2. Credit Purchase (handlePurchaseCredits - Lines 330-352):** +```typescript +// For Stripe/PayPal: +const { redirect_url } = await purchaseCredits(packageId.toString(), selectedGateway); +window.location.href = redirect_url; + +// For Manual/Bank Transfer: +await purchaseCreditPackage({ package_id: packageId, payment_method: selectedPaymentMethod }); +``` + +**3. Return URL Handling (Lines 100-155):** +```typescript +// PayPal Success - capture order +if (paypalStatus === 'success' && paypalToken) { + await capturePayPalOrder(paypalToken, { plan_id, package_id }); +} +// Stripe Success +if (success === 'true') { + toast.success('Subscription activated!'); + await refreshUser(); +} +``` + +--- + +### 3. PayInvoiceModal.tsx + +**File:** [frontend/src/components/billing/PayInvoiceModal.tsx](../../../frontend/src/components/billing/PayInvoiceModal.tsx) + +**Purpose:** Modal for paying pending invoices with multiple payment methods. + +#### Key Features (Lines 1-544) + +``` +- Payment Method Selection: Lines 55-95 +- Stripe Payment Handler: Lines 115-140 +- PayPal Payment Handler: Lines 142-167 +- Bank Transfer Form: Lines 203-250 +``` + +#### Payment Method Logic (Lines 55-95): + +```typescript +const isPakistan = userCountry?.toUpperCase() === 'PK'; + +// Available options based on country: +const availableOptions: PaymentOption[] = isPakistan + ? ['stripe', 'bank_transfer'] + : ['stripe', 'paypal']; +``` + +#### Stripe Payment (Lines 115-140): + +```typescript +const handleStripePayment = async () => { + const result = await subscribeToPlan(planIdentifier, 'stripe', { + return_url: `${window.location.origin}/account/plans?success=true`, + cancel_url: `${window.location.origin}/account/plans?canceled=true`, + }); + window.location.href = result.redirect_url; +}; +``` + +#### Bank Transfer Submission (Lines 203-250): + +```typescript +const handleBankSubmit = async () => { + await fetch(`${API_BASE_URL}/v1/billing/admin/payments/confirm/`, { + method: 'POST', + body: JSON.stringify({ + invoice_id: invoice.id, + payment_method: 'bank_transfer', + amount: invoice.total_amount, + manual_reference: bankFormData.manual_reference, + manual_notes: bankFormData.manual_notes, + proof_url: bankFormData.proof_url, + }), + }); +}; +``` + +--- + +### 4. PendingPaymentBanner.tsx + +**File:** [frontend/src/components/billing/PendingPaymentBanner.tsx](../../../frontend/src/components/billing/PendingPaymentBanner.tsx) + +**Purpose:** Alert banner displayed when account status is `pending_payment`. + +#### Key Features (Lines 1-338) + +``` +- Account Status Check: Lines 45-48 +- Pending Invoice Loading: Lines 60-130 +- Payment Gateway Detection: Lines 100-125 +- Pay Invoice Modal Trigger: Line 160 +``` + +#### Display Logic: + +```typescript +const accountStatus = user?.account?.status; +const isPendingPayment = accountStatus === 'pending_payment'; +``` + +#### Loads: + +1. Pending invoices from `/v1/billing/invoices/?status=pending&limit=1` +2. Account payment methods from `/v1/billing/payment-methods/` +3. Available gateways from `/v1/billing/stripe/config/` and `/v1/billing/paypal/config/` + +--- + +## Frontend Payment Services + +### billing.api.ts + +**File:** [frontend/src/services/billing.api.ts](../../../frontend/src/services/billing.api.ts) + +**Total Lines:** 1443 + +#### Key Payment Functions: + +| Function | Lines | Endpoint | Description | +|----------|-------|----------|-------------| +| `getStripeConfig` | 1095-1097 | GET `/v1/billing/stripe/config/` | Get Stripe publishable key | +| `createStripeCheckout` | 1102-1112 | POST `/v1/billing/stripe/checkout/` | Create checkout session | +| `createStripeCreditCheckout` | 1200-1215 | POST `/v1/billing/stripe/credit-checkout/` | Credit package checkout | +| `openStripeBillingPortal` | 1220-1230 | POST `/v1/billing/stripe/billing-portal/` | Subscription management | +| `getPayPalConfig` | 1270-1272 | GET `/v1/billing/paypal/config/` | Get PayPal client ID | +| `createPayPalCreditOrder` | 1278-1290 | POST `/v1/billing/paypal/create-order/` | Credit package order | +| `createPayPalSubscriptionOrder` | 1295-1307 | POST `/v1/billing/paypal/create-subscription-order/` | Subscription order | +| `capturePayPalOrder` | 1312-1325 | POST `/v1/billing/paypal/capture-order/` | Capture approved order | +| `subscribeToPlan` | 1365-1380 | Gateway-specific | Unified subscribe helper | +| `purchaseCredits` | 1385-1400 | Gateway-specific | Unified purchase helper | +| `getInvoices` | 480-492 | GET `/v1/billing/invoices/` | List invoices | +| `getPayments` | 510-522 | GET `/v1/billing/payments/` | List payments | +| `submitManualPayment` | 528-542 | POST `/v1/billing/payments/manual/` | Submit bank transfer | +| `getAvailablePaymentMethods` | 648-665 | GET `/v1/billing/payment-configs/payment-methods/` | Get payment configs | + +#### Helper Functions (Lines 1355-1443): + +```typescript +// Subscribe to plan using preferred gateway +export async function subscribeToPlan( + planId: string, + gateway: PaymentGateway, + options?: { return_url?: string; cancel_url?: string } +): Promise<{ redirect_url: string }> { + switch (gateway) { + case 'stripe': return await createStripeCheckout(planId, options); + case 'paypal': return await createPayPalSubscriptionOrder(planId, options); + case 'manual': throw new Error('Use submitManualPayment()'); + } +} + +// Purchase credit package using preferred gateway +export async function purchaseCredits( + packageId: string, + gateway: PaymentGateway, + options?: { return_url?: string; cancel_url?: string } +): Promise<{ redirect_url: string }> { + switch (gateway) { + case 'stripe': return await createStripeCreditCheckout(packageId, options); + case 'paypal': return await createPayPalCreditOrder(packageId, options); + case 'manual': throw new Error('Use submitManualPayment()'); + } +} +``` + +--- + +### authStore.ts + +**File:** [frontend/src/store/authStore.ts](../../../frontend/src/store/authStore.ts) + +**Total Lines:** 534 + +#### Register Function (Lines 230-395): + +```typescript +register: async (registerData) => { + const response = await fetch(`${API_BASE_URL}/v1/auth/register/`, { + method: 'POST', + body: JSON.stringify({ + ...registerData, + password_confirm: registerData.password, + plan_slug: registerData.plan_slug, + }), + }); + + const data = await response.json(); + + // Extract checkout_url for payment redirect + return { + ...userData, + checkout_url: responseData.checkout_url || data.checkout_url, + checkout_session_id: responseData.checkout_session_id, + paypal_order_id: responseData.paypal_order_id, + }; +} +``` + +**Key Return Fields:** +- `checkout_url` - Stripe/PayPal redirect URL +- `checkout_session_id` - Stripe session ID +- `paypal_order_id` - PayPal order ID + +--- + +## Backend Endpoints + +### Billing URLs + +**File:** [backend/igny8_core/business/billing/urls.py](../../../backend/igny8_core/business/billing/urls.py) + +```python +# Stripe Endpoints +path('stripe/config/', StripeConfigView.as_view()) +path('stripe/checkout/', StripeCheckoutView.as_view()) +path('stripe/credit-checkout/', StripeCreditCheckoutView.as_view()) +path('stripe/billing-portal/', StripeBillingPortalView.as_view()) +path('webhooks/stripe/', stripe_webhook) + +# PayPal Endpoints +path('paypal/config/', PayPalConfigView.as_view()) +path('paypal/create-order/', PayPalCreateOrderView.as_view()) +path('paypal/create-subscription-order/', PayPalCreateSubscriptionOrderView.as_view()) +path('paypal/capture-order/', PayPalCaptureOrderView.as_view()) +path('paypal/create-subscription/', PayPalCreateSubscriptionView.as_view()) +path('webhooks/paypal/', paypal_webhook) + +# Router ViewSets +router.register(r'invoices', InvoiceViewSet) +router.register(r'payments', PaymentViewSet) +router.register(r'credit-packages', CreditPackageViewSet) +router.register(r'payment-methods', AccountPaymentMethodViewSet) +router.register(r'payment-configs', BillingViewSet) +``` + +--- + +### Stripe Views + +**File:** [backend/igny8_core/business/billing/views/stripe_views.py](../../../backend/igny8_core/business/billing/views/stripe_views.py) + +**Total Lines:** 802 + +| View | Lines | Method | Endpoint | Description | +|------|-------|--------|----------|-------------| +| `StripeConfigView` | 35-55 | GET | `/stripe/config/` | Returns publishable key | +| `StripeCheckoutView` | 58-125 | POST | `/stripe/checkout/` | Creates subscription checkout | +| `StripeCreditCheckoutView` | 128-200 | POST | `/stripe/credit-checkout/` | Creates credit checkout | +| `StripeBillingPortalView` | 203-260 | POST | `/stripe/billing-portal/` | Creates portal session | +| `stripe_webhook` | 263-350 | POST | `/webhooks/stripe/` | Handles Stripe webhooks | + +--- + +### PayPal Views + +**File:** [backend/igny8_core/business/billing/views/paypal_views.py](../../../backend/igny8_core/business/billing/views/paypal_views.py) + +**Total Lines:** 910 + +| View | Lines | Method | Endpoint | Description | +|------|-------|--------|----------|-------------| +| `PayPalConfigView` | 42-58 | GET | `/paypal/config/` | Returns client ID | +| `PayPalCreateOrderView` | 61-135 | POST | `/paypal/create-order/` | Creates credit order | +| `PayPalCreateSubscriptionOrderView` | 138-230 | POST | `/paypal/create-subscription-order/` | Creates subscription order | +| `PayPalCaptureOrderView` | 233-400 | POST | `/paypal/capture-order/` | Captures approved order | +| `PayPalCreateSubscriptionView` | 403-500 | POST | `/paypal/create-subscription/` | Creates recurring subscription | +| `paypal_webhook` | 503-700 | POST | `/webhooks/paypal/` | Handles PayPal webhooks | + +--- + +### Auth Register Endpoint + +**File:** [backend/igny8_core/auth/urls.py](../../../backend/igny8_core/auth/urls.py) (Lines 40-170) + +**Also:** [backend/igny8_core/auth/serializers.py](../../../backend/igny8_core/auth/serializers.py) (Lines 280-500) + +#### RegisterSerializer.create() Flow (Lines 340-500): + +1. **Plan Lookup** (Lines 340-365): + ```python + if plan_slug in ['starter', 'growth', 'scale']: + plan = Plan.objects.get(slug=plan_slug, is_active=True) + account_status = 'pending_payment' + initial_credits = 0 + else: + plan = Plan.objects.get(slug='free', is_active=True) + account_status = 'trial' + initial_credits = plan.included_credits + ``` + +2. **Account Creation** (Lines 390-420): + ```python + account = Account.objects.create( + name=account_name, + owner=user, + plan=plan, + credits=initial_credits, + status=account_status, # 'pending_payment' or 'trial' + payment_method=validated_data.get('payment_method', 'bank_transfer'), + billing_country=validated_data.get('billing_country', ''), + ) + ``` + +3. **For Paid Plans** (Lines 445-485): + ```python + # Create Subscription + subscription = Subscription.objects.create( + account=account, + plan=plan, + status='pending_payment', + current_period_start=billing_period_start, + current_period_end=billing_period_end, + ) + + # Create Invoice + InvoiceService.create_subscription_invoice(subscription, ...) + + # Create AccountPaymentMethod + AccountPaymentMethod.objects.create( + account=account, + type=payment_method, + is_default=True, + is_verified=False, + ) + ``` + +#### Checkout Session Creation (RegisterView - Lines 118-165): + +```python +# For Stripe +if payment_method == 'stripe': + checkout_data = stripe_service.create_checkout_session( + account=account, + plan=account.plan, + ) + response_data['checkout_url'] = checkout_data['checkout_url'] + +# For PayPal +elif payment_method == 'paypal': + order = paypal_service.create_order( + account=account, + amount=float(account.plan.price), + description=f'{account.plan.name} Plan Subscription', + ) + response_data['checkout_url'] = order['approval_url'] +``` + +--- + +## Backend Services + +### StripeService + +**File:** [backend/igny8_core/business/billing/services/stripe_service.py](../../../backend/igny8_core/business/billing/services/stripe_service.py) + +**Total Lines:** 628 + +#### Key Methods: + +| Method | Lines | Description | +|--------|-------|-------------| +| `__init__` | 28-60 | Initialize from IntegrationProvider | +| `_get_or_create_customer` | 73-110 | Get/create Stripe customer | +| `create_checkout_session` | 130-210 | Create subscription checkout | +| `create_credit_checkout_session` | 215-280 | Create credit package checkout | +| `create_billing_portal_session` | 330-375 | Create portal session | +| `get_subscription` | 380-420 | Get subscription details | +| `cancel_subscription` | 425-450 | Cancel subscription | +| `construct_webhook_event` | 485-500 | Verify webhook signature | +| `create_refund` | 555-600 | Create refund | + +#### Checkout Session Flow: + +```python +def create_checkout_session(self, account, plan, success_url=None, cancel_url=None): + customer_id = self._get_or_create_customer(account) + + session = stripe.checkout.Session.create( + customer=customer_id, + payment_method_types=['card'], + mode='subscription', + line_items=[{'price': plan.stripe_price_id, 'quantity': 1}], + success_url=success_url or f'{self.frontend_url}/account/plans?success=true', + cancel_url=cancel_url or f'{self.frontend_url}/account/plans?canceled=true', + metadata={ + 'account_id': str(account.id), + 'plan_id': str(plan.id), + 'type': 'subscription', + }, + ) + + return {'checkout_url': session.url, 'session_id': session.id} +``` + +--- + +### PayPalService + +**File:** [backend/igny8_core/business/billing/services/paypal_service.py](../../../backend/igny8_core/business/billing/services/paypal_service.py) + +**Total Lines:** 680 + +#### Key Methods: + +| Method | Lines | Description | +|--------|-------|-------------| +| `__init__` | 44-85 | Initialize from IntegrationProvider | +| `_get_access_token` | 105-140 | Get OAuth token | +| `_make_request` | 145-195 | Make authenticated request | +| `create_order` | 220-275 | Create one-time order | +| `create_credit_order` | 300-340 | Create credit package order | +| `capture_order` | 345-400 | Capture approved order | +| `create_subscription` | 420-480 | Create recurring subscription | +| `cancel_subscription` | 510-535 | Cancel subscription | +| `verify_webhook_signature` | 565-610 | Verify webhook | +| `refund_capture` | 615-660 | Refund payment | + +#### Order Flow: + +```python +def create_order(self, account, amount, description='', return_url=None, cancel_url=None): + order_data = { + 'intent': 'CAPTURE', + 'purchase_units': [{ + 'amount': {'currency_code': self.currency, 'value': f'{amount:.2f}'}, + 'description': description or 'IGNY8 Payment', + 'custom_id': str(account.id), + }], + 'application_context': { + 'return_url': return_url or self.return_url, + 'cancel_url': cancel_url or self.cancel_url, + 'brand_name': 'IGNY8', + 'user_action': 'PAY_NOW', + } + } + + response = self._make_request('POST', '/v2/checkout/orders', json_data=order_data) + + return { + 'order_id': response.get('id'), + 'status': response.get('status'), + 'approval_url': approval_url, # From links + } +``` + +--- + +### InvoiceService + +**File:** [backend/igny8_core/business/billing/services/invoice_service.py](../../../backend/igny8_core/business/billing/services/invoice_service.py) + +**Total Lines:** 385 + +#### Key Methods: + +| Method | Lines | Description | +|--------|-------|-------------| +| `get_pending_invoice` | 18-27 | Get pending invoice for subscription | +| `get_or_create_subscription_invoice` | 30-50 | Avoid duplicate invoices | +| `generate_invoice_number` | 53-80 | Generate unique invoice number | +| `create_subscription_invoice` | 85-165 | Create subscription invoice | +| `create_credit_package_invoice` | 170-230 | Create credit package invoice | +| `mark_paid` | 260-290 | Mark invoice as paid | +| `mark_void` | 295-310 | Void invoice | + +#### Currency Logic: + +```python +# Online payments (stripe, paypal): Always USD +# Manual payments (bank_transfer, local_wallet): Local currency + +if payment_method in ['stripe', 'paypal']: + currency = 'USD' + local_price = float(plan.price) +else: + currency = get_currency_for_country(account.billing_country) # 'PKR' for PK + local_price = convert_usd_to_local(float(plan.price), account.billing_country) +``` + +--- + +### PaymentService + +**File:** [backend/igny8_core/business/billing/services/payment_service.py](../../../backend/igny8_core/business/billing/services/payment_service.py) + +**Total Lines:** 418 + +#### Key Methods: + +| Method | Lines | Description | +|--------|-------|-------------| +| `create_stripe_payment` | 17-35 | Record Stripe payment | +| `create_paypal_payment` | 40-55 | Record PayPal payment | +| `create_manual_payment` | 60-92 | Record manual payment (pending_approval) | +| `mark_payment_completed` | 97-130 | Mark as succeeded | +| `mark_payment_failed` | 135-150 | Mark as failed | +| `approve_manual_payment` | 155-200 | Admin approves manual payment | +| `reject_manual_payment` | 205-230 | Admin rejects manual payment | +| `_add_credits_for_payment` | 235-270 | Add credits after payment | +| `get_available_payment_methods` | 275-340 | Get methods by country | + +#### Manual Payment Approval: + +```python +def approve_manual_payment(payment, approved_by_user_id, admin_notes=None): + payment.status = 'succeeded' + payment.approved_by_id = approved_by_user_id + payment.approved_at = timezone.now() + payment.save() + + # Update invoice + InvoiceService.mark_paid(payment.invoice, payment_method=payment.payment_method) + + # Add credits if credit package + if payment.metadata.get('credit_package_id'): + PaymentService._add_credits_for_payment(payment) + + # Activate account + if account.status != 'active': + account.status = 'active' + account.save() +``` + +--- + +## Models + +### Account Model + +**File:** [backend/igny8_core/auth/models.py](../../../backend/igny8_core/auth/models.py) (Lines 55-145) + +```python +class Account(SoftDeletableModel): + STATUS_CHOICES = [ + ('active', 'Active'), + ('suspended', 'Suspended'), + ('trial', 'Trial'), + ('cancelled', 'Cancelled'), + ('pending_payment', 'Pending Payment'), + ] + + PAYMENT_METHOD_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ] + + name = models.CharField(max_length=255) + slug = models.SlugField(unique=True) + owner = models.ForeignKey('User', on_delete=models.SET_NULL, null=True) + stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) + plan = models.ForeignKey('Plan', on_delete=models.PROTECT) + credits = models.IntegerField(default=0) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial') + payment_method = models.CharField(max_length=30, choices=PAYMENT_METHOD_CHOICES, default='stripe') + + # Billing information + billing_email = models.EmailField(blank=True, null=True) + billing_address_line1 = models.CharField(max_length=255, blank=True) + billing_country = models.CharField(max_length=2, blank=True) # ISO country code + tax_id = models.CharField(max_length=100, blank=True) +``` + +--- + +### Subscription Model + +**File:** [backend/igny8_core/auth/models.py](../../../backend/igny8_core/auth/models.py) (Lines 395-440) + +```python +class Subscription(models.Model): + STATUS_CHOICES = [ + ('active', 'Active'), + ('past_due', 'Past Due'), + ('canceled', 'Canceled'), + ('trialing', 'Trialing'), + ('pending_payment', 'Pending Payment'), + ] + + account = models.OneToOneField('Account', on_delete=models.CASCADE, related_name='subscription') + plan = models.ForeignKey('Plan', on_delete=models.PROTECT) + stripe_subscription_id = models.CharField(max_length=255, blank=True, null=True) + external_payment_id = models.CharField(max_length=255, blank=True, null=True) # PayPal/Bank ref + status = models.CharField(max_length=20, choices=STATUS_CHOICES) + current_period_start = models.DateTimeField() + current_period_end = models.DateTimeField() + cancel_at_period_end = models.BooleanField(default=False) +``` + +--- + +### Plan Model + +**File:** [backend/igny8_core/auth/models.py](../../../backend/igny8_core/auth/models.py) (Lines 300-395) + +```python +class Plan(models.Model): + name = models.CharField(max_length=255) + slug = models.SlugField(unique=True) + price = models.DecimalField(max_digits=10, decimal_places=2) + billing_cycle = models.CharField(max_length=20, choices=[('monthly', 'Monthly'), ('annual', 'Annual')]) + is_active = models.BooleanField(default=True) + is_internal = models.BooleanField(default=False) # Hidden from public (Free plan) + + # Limits + max_users = models.IntegerField(default=1) + max_sites = models.IntegerField(default=1) + max_keywords = models.IntegerField(default=1000) + + # Credits + included_credits = models.IntegerField(default=0) + + # Payment Integration + stripe_product_id = models.CharField(max_length=255, blank=True, null=True) + stripe_price_id = models.CharField(max_length=255, blank=True, null=True) +``` + +--- + +### Invoice Model + +**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 310-380) + +```python +class Invoice(AccountBaseModel): + STATUS_CHOICES = [ + ('draft', 'Draft'), + ('pending', 'Pending'), + ('paid', 'Paid'), + ('void', 'Void'), + ('uncollectible', 'Uncollectible'), + ] + + invoice_number = models.CharField(max_length=50, unique=True) + subscription = models.ForeignKey('Subscription', on_delete=models.SET_NULL, null=True) + subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0) + tax = models.DecimalField(max_digits=10, decimal_places=2, default=0) + total = models.DecimalField(max_digits=10, decimal_places=2, default=0) + currency = models.CharField(max_length=3, default='USD') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + invoice_date = models.DateField() + due_date = models.DateField() + paid_at = models.DateTimeField(null=True, blank=True) + line_items = models.JSONField(default=list) + stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True) + payment_method = models.CharField(max_length=50, null=True, blank=True) + metadata = models.JSONField(default=dict) +``` + +--- + +### Payment Model + +**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 460-540) + +```python +class Payment(AccountBaseModel): + STATUS_CHOICES = [ + ('pending_approval', 'Pending Approval'), # Manual payment awaiting admin + ('succeeded', 'Succeeded'), + ('failed', 'Failed'), + ('refunded', 'Refunded'), + ] + + PAYMENT_METHOD_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ('local_wallet', 'Local Wallet'), + ('manual', 'Manual Payment'), + ] + + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=3, default='USD') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_approval') + payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) + + # Stripe + stripe_payment_intent_id = models.CharField(max_length=255, null=True, blank=True) + stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) + + # PayPal + paypal_order_id = models.CharField(max_length=255, null=True, blank=True) + paypal_capture_id = models.CharField(max_length=255, null=True, blank=True) + + # Manual + manual_reference = models.CharField(max_length=255, blank=True) # Bank transfer ref + manual_notes = models.TextField(blank=True) + admin_notes = models.TextField(blank=True) + approved_by = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL) + approved_at = models.DateTimeField(null=True, blank=True) + + metadata = models.JSONField(default=dict) +``` + +--- + +### AccountPaymentMethod Model + +**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 700-750) + +```python +class AccountPaymentMethod(AccountBaseModel): + """Account-scoped payment methods (user's verified payment options)""" + PAYMENT_METHOD_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ('local_wallet', 'Local Wallet'), + ('manual', 'Manual Payment'), + ] + + type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) + display_name = models.CharField(max_length=100) + is_default = models.BooleanField(default=False) + is_enabled = models.BooleanField(default=True) + is_verified = models.BooleanField(default=False) # True after successful payment + country_code = models.CharField(max_length=2, blank=True) + instructions = models.TextField(blank=True) + metadata = models.JSONField(default=dict) +``` + +--- + +### PaymentMethodConfig Model + +**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 600-695) + +```python +class PaymentMethodConfig(models.Model): + """System-level payment method configuration per country""" + PAYMENT_METHOD_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ('local_wallet', 'Local Wallet'), + ('manual', 'Manual Payment'), + ] + + country_code = models.CharField(max_length=2) # 'US', 'PK', '*' for global + payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) + is_enabled = models.BooleanField(default=True) + display_name = models.CharField(max_length=100, blank=True) + instructions = models.TextField(blank=True) + + # Bank Transfer Details + bank_name = models.CharField(max_length=255, blank=True) + account_number = models.CharField(max_length=255, blank=True) + account_title = models.CharField(max_length=255, blank=True) + routing_number = models.CharField(max_length=255, blank=True) + swift_code = models.CharField(max_length=255, blank=True) + iban = models.CharField(max_length=255, blank=True) + + # Local Wallet Details + wallet_type = models.CharField(max_length=100, blank=True) # JazzCash, EasyPaisa + wallet_id = models.CharField(max_length=255, blank=True) + + sort_order = models.IntegerField(default=0) + + class Meta: + unique_together = [['country_code', 'payment_method']] +``` + +--- + +## Payment Method Configuration + +### Country-Based Payment Methods + +| Country | Stripe | PayPal | Bank Transfer | Local Wallet | +|---------|--------|--------|---------------|--------------| +| Global (`*`) | ✅ | ✅ | ❌ | ❌ | +| Pakistan (`PK`) | ✅ | ❌ | ✅ | ✅ (JazzCash, EasyPaisa) | + +### Configuration Flow + +1. **PaymentMethodConfig** stores system-level configs per country +2. **AccountPaymentMethod** stores user's saved/verified methods +3. Frontend queries `/v1/billing/payment-configs/payment-methods/` to get available options +4. Frontend filters based on user's country (from `countryCode` prop or account's `billing_country`) + +--- + +## Payment Flows + +### Flow 1: Signup with Stripe + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant B as Backend + participant S as Stripe + + U->>F: Fill signup form, select Stripe + F->>B: POST /v1/auth/register/ {payment_method: 'stripe', plan_slug: 'starter'} + B->>B: Create User, Account (status=pending_payment), Subscription, Invoice + B->>S: Create Checkout Session + S-->>B: checkout_url + B-->>F: {user, tokens, checkout_url} + F->>S: Redirect to checkout_url + U->>S: Complete payment + S->>B: POST /webhooks/stripe/ (checkout.session.completed) + B->>B: Activate subscription, mark invoice paid, add credits + S->>F: Redirect to success_url + F->>B: GET /v1/auth/me/ (refresh user) +``` + +### Flow 2: Signup with Bank Transfer + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant B as Backend + participant A as Admin + + U->>F: Fill signup form, select Bank Transfer + F->>B: POST /v1/auth/register/ {payment_method: 'bank_transfer'} + B->>B: Create User, Account (status=pending_payment), Subscription, Invoice, AccountPaymentMethod + B-->>F: {user, tokens} + F->>F: Navigate to /account/plans (shows PendingPaymentBanner) + U->>F: Make bank transfer, enter reference + F->>B: POST /v1/billing/admin/payments/confirm/ {invoice_id, manual_reference} + B->>B: Create Payment (status=pending_approval) + A->>B: POST /v1/admin/billing/{id}/approve_payment/ + B->>B: Mark payment succeeded, invoice paid, activate account, add credits +``` + +### Flow 3: Credit Purchase with PayPal + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant B as Backend + participant P as PayPal + + U->>F: Click Buy Credits, select PayPal + F->>B: POST /v1/billing/paypal/create-order/ {package_id} + B->>P: Create Order + P-->>B: {order_id, approval_url} + B-->>F: {order_id, approval_url} + F->>P: Redirect to approval_url + U->>P: Approve payment + P->>F: Redirect to return_url with token + F->>B: POST /v1/billing/paypal/capture-order/ {order_id} + B->>P: Capture Order + P-->>B: {capture_id, status: COMPLETED} + B->>B: Create invoice, payment, add credits + B-->>F: {credits_added, new_balance} +``` + +### Flow 4: Pay Pending Invoice (PayInvoiceModal) + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant B as Backend + participant S as Stripe + + U->>F: Click "Pay Invoice" button + F->>F: Open PayInvoiceModal + U->>F: Select Stripe, click Pay + F->>B: POST /v1/billing/stripe/checkout/ {plan_id} + B->>S: Create Checkout Session + S-->>B: checkout_url + B-->>F: {checkout_url} + F->>S: Redirect to checkout_url + U->>S: Complete payment + S->>B: Webhook: checkout.session.completed + B->>B: Update subscription, invoice, account status +``` + +--- + +## Webhooks + +### Stripe Webhooks + +**Endpoint:** `POST /v1/billing/webhooks/stripe/` + +| Event | Handler | Action | +|-------|---------|--------| +| `checkout.session.completed` | `_handle_checkout_completed` | Activate subscription OR add purchased credits | +| `invoice.paid` | `_handle_invoice_paid` | Add monthly credits for renewal | +| `invoice.payment_failed` | `_handle_payment_failed` | Send notification, update status | +| `customer.subscription.updated` | `_handle_subscription_updated` | Sync plan changes | +| `customer.subscription.deleted` | `_handle_subscription_deleted` | Cancel subscription | + +#### Checkout Completed Handler (Lines 355-475): + +```python +def _handle_checkout_completed(session): + metadata = session.get('metadata', {}) + account_id = metadata.get('account_id') + mode = session.get('mode') + + if mode == 'subscription': + _activate_subscription(account, subscription_id, plan_id, session) + elif mode == 'payment': + _add_purchased_credits(account, credit_package_id, credit_amount, session) +``` + +### PayPal Webhooks + +**Endpoint:** `POST /v1/billing/webhooks/paypal/` + +| Event | Action | +|-------|--------| +| `CHECKOUT.ORDER.APPROVED` | Auto-capture (if configured) | +| `PAYMENT.CAPTURE.COMPLETED` | Mark payment succeeded, add credits | +| `PAYMENT.CAPTURE.DENIED` | Mark payment failed | +| `BILLING.SUBSCRIPTION.ACTIVATED` | Activate subscription | +| `BILLING.SUBSCRIPTION.CANCELLED` | Cancel subscription | +| `BILLING.SUBSCRIPTION.PAYMENT.FAILED` | Handle failed renewal | + +--- + +## Summary + +### Key Files Reference + +| Category | File | Lines | +|----------|------|-------| +| **Frontend Entry Points** | | | +| Signup Form | `frontend/src/components/auth/SignUpFormUnified.tsx` | 770 | +| Plans & Billing Page | `frontend/src/pages/account/PlansAndBillingPage.tsx` | 1197 | +| Pay Invoice Modal | `frontend/src/components/billing/PayInvoiceModal.tsx` | 544 | +| Pending Payment Banner | `frontend/src/components/billing/PendingPaymentBanner.tsx` | 338 | +| **Frontend Services** | | | +| Billing API | `frontend/src/services/billing.api.ts` | 1443 | +| Auth Store | `frontend/src/store/authStore.ts` | 534 | +| **Backend Views** | | | +| Stripe Views | `backend/igny8_core/business/billing/views/stripe_views.py` | 802 | +| PayPal Views | `backend/igny8_core/business/billing/views/paypal_views.py` | 910 | +| **Backend Services** | | | +| Stripe Service | `backend/igny8_core/business/billing/services/stripe_service.py` | 628 | +| PayPal Service | `backend/igny8_core/business/billing/services/paypal_service.py` | 680 | +| Invoice Service | `backend/igny8_core/business/billing/services/invoice_service.py` | 385 | +| Payment Service | `backend/igny8_core/business/billing/services/payment_service.py` | 418 | +| **Models** | | | +| Auth Models | `backend/igny8_core/auth/models.py` | 866 | +| Billing Models | `backend/igny8_core/business/billing/models.py` | 857 | +| **Serializers** | | | +| Auth Serializers | `backend/igny8_core/auth/serializers.py` | 561 | +| **URLs** | | | +| Billing URLs | `backend/igny8_core/business/billing/urls.py` | 73 | +| Auth URLs | `backend/igny8_core/auth/urls.py` | 461 | + +--- + +## Quick Reference: API Endpoints + +### Stripe + +``` +GET /v1/billing/stripe/config/ - Get publishable key +POST /v1/billing/stripe/checkout/ - Create subscription checkout +POST /v1/billing/stripe/credit-checkout/ - Create credit checkout +POST /v1/billing/stripe/billing-portal/ - Open billing portal +POST /v1/billing/webhooks/stripe/ - Webhook handler +``` + +### PayPal + +``` +GET /v1/billing/paypal/config/ - Get client ID +POST /v1/billing/paypal/create-order/ - Create credit order +POST /v1/billing/paypal/create-subscription-order/ - Create subscription order +POST /v1/billing/paypal/capture-order/ - Capture approved order +POST /v1/billing/paypal/create-subscription/ - Create recurring subscription +POST /v1/billing/webhooks/paypal/ - Webhook handler +``` + +### Invoices & Payments + +``` +GET /v1/billing/invoices/ - List invoices +GET /v1/billing/invoices/{id}/ - Get invoice detail +GET /v1/billing/invoices/{id}/download_pdf/ - Download PDF +GET /v1/billing/payments/ - List payments +POST /v1/billing/payments/manual/ - Submit manual payment +``` + +### Admin Payment Management + +``` +GET /v1/admin/billing/pending_payments/ - List pending approvals +POST /v1/admin/billing/{id}/approve_payment/ - Approve payment +POST /v1/admin/billing/{id}/reject_payment/ - Reject payment +``` + +### Payment Methods + +``` +GET /v1/billing/payment-methods/ - Get user's payment methods +POST /v1/billing/payment-methods/ - Create payment method +POST /v1/billing/payment-methods/{id}/set_default/ - Set default +GET /v1/billing/payment-configs/payment-methods/ - Get available configs +``` diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md new file mode 100644 index 00000000..ff2321e9 --- /dev/null +++ b/docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md @@ -0,0 +1,959 @@ +# Payment System Audit Report + +> **Audit Date:** January 7, 2026 +> **Status:** Complete (Updated with Frontend UX Audit) +> **Severity Levels:** CRITICAL | HIGH | MEDIUM | LOW + +--- + +## Executive Summary + +The IGNY8 payment system documentation is **accurate and matches the implementation**. However, the deep audit revealed **45+ issues** across security, reliability, UX, and functionality areas. The most critical concerns involve: + +1. **PayPal webhook signature verification disabled** (security risk) +2. **Missing idempotency in payment processing** (double-charge risk) +3. **No admin dashboard for manual payment approval** (operational gap) +4. **Plan shows "Active" even with unpaid invoice** (misleading UX) +5. **Payment options not properly restricted by state** (UX confusion) +6. **Hardcoded currency exchange rates** (financial accuracy) +7. **Refund functions reference non-existent modules** (broken feature) + +--- + +## Documentation Verification + +### All Documented Files: VERIFIED + +| Category | File | Status | +|----------|------|--------| +| Frontend Entry Points | SignUpFormUnified.tsx | EXISTS | +| | PlansAndBillingPage.tsx | EXISTS | +| | PayInvoiceModal.tsx | EXISTS | +| | PendingPaymentBanner.tsx | EXISTS | +| Frontend Services | billing.api.ts | EXISTS | +| | authStore.ts | EXISTS | +| Backend Views | stripe_views.py | EXISTS | +| | paypal_views.py | EXISTS | +| Backend Services | stripe_service.py | EXISTS | +| | paypal_service.py | EXISTS | +| | payment_service.py | EXISTS | +| | invoice_service.py | EXISTS | +| Models | billing/models.py | EXISTS | +| | auth/models.py | EXISTS | + +### Country-Based Payment Logic: CORRECT + +- **Pakistan (PK):** Stripe + Bank Transfer (NO PayPal) +- **Global:** Stripe + PayPal + +Logic correctly implemented in: +- `SignUpFormUnified.tsx:160-186` +- `PayInvoiceModal.tsx:69-97` +- `payment_service.py:260-263` + +--- + +## PlansAndBillingPage UX Audit (NEW) + +### Overview + +The `/account/plans` page (`PlansAndBillingPage.tsx`) is the central hub for subscription management. This audit identifies **critical UX issues** related to state handling, invoice lifecycle, and payment option restrictions. + +--- + +### CRITICAL UX Issue #1: Plan Shows "Active" Even With Unpaid Invoice + +**Location:** `PlansAndBillingPage.tsx:459-461` + +**Current Code:** +```tsx + + {hasActivePlan ? 'Active' : 'Inactive'} + +``` + +**Problem:** +- `hasActivePlan` is `true` if user has ANY plan assigned (Line 384) +- User who signed up for paid plan but never completed payment sees **"Active"** +- This is misleading - their account is actually `pending_payment` + +**Correct Logic Should Check:** +1. `account.status === 'active'` (not just plan existence) +2. No pending invoices +3. Subscription status is `active` (not `pending_payment`) + +**Fix Required:** +```tsx +const accountStatus = user?.account?.status; +const subscriptionStatus = currentSubscription?.status; +const isFullyActive = accountStatus === 'active' && + subscriptionStatus === 'active' && + !hasPendingInvoice; + + + {isFullyActive ? 'Active' : accountStatus === 'pending_payment' ? 'Pending Payment' : 'Inactive'} + +``` + +--- + +### CRITICAL UX Issue #2: Subscription States Not Properly Reflected + +**Location:** `PlansAndBillingPage.tsx:379-386` + +**Current Code:** +```tsx +const currentSubscription = subscriptions.find((sub) => sub.status === 'active') || subscriptions[0]; +// ... +const hasActivePlan = Boolean(effectivePlanId); +``` + +**Problem:** +The page doesn't distinguish between subscription statuses: +- `active` - Paid and working +- `pending_payment` - Waiting for first payment +- `past_due` - Renewal payment failed +- `canceled` - User cancelled + +**Missing Status Handling:** + +| Subscription Status | What User Sees | What They SHOULD See | +|---------------------|----------------|----------------------| +| `pending_payment` | "Active" badge | "Payment Required" with prominent CTA | +| `past_due` | No indication | "Payment Overdue" warning | +| `canceled` | May still show "Active" | "Cancels on [date]" | +| `trialing` | "Active" | "Trial (X days left)" | + +**Fix Required:** Add comprehensive status display: +```tsx +const getSubscriptionDisplay = () => { + if (!currentSubscription) return { label: 'No Plan', tone: 'error' }; + + switch (currentSubscription.status) { + case 'active': + return hasPendingInvoice + ? { label: 'Payment Due', tone: 'warning' } + : { label: 'Active', tone: 'success' }; + case 'pending_payment': + return { label: 'Awaiting Payment', tone: 'warning' }; + case 'past_due': + return { label: 'Payment Overdue', tone: 'error' }; + case 'canceled': + return { label: `Cancels ${formatDate(currentSubscription.cancel_at)}`, tone: 'warning' }; + case 'trialing': + return { label: `Trial`, tone: 'info' }; + default: + return { label: currentSubscription.status, tone: 'neutral' }; + } +}; +``` + +--- + +### HIGH UX Issue #3: Upgrade Button Available When Payment Pending + +**Location:** `PlansAndBillingPage.tsx:478-486` + +**Current Code:** +```tsx + +``` + +**Problem:** User with pending invoice can click "Upgrade" and attempt to subscribe to another plan, creating confusion and potentially duplicate subscriptions. + +**Fix Required:** +```tsx + +``` + +--- + +### HIGH UX Issue #4: Cancel Subscription Available When Account Already Pending + +**Location:** `PlansAndBillingPage.tsx:609-616` + +**Current Code:** +```tsx +{hasActivePlan && ( + +)} +``` + +**Problem:** User with `pending_payment` status can "cancel" a subscription they never paid for. This is confusing. + +**Fix Required:** +```tsx +{hasActivePlan && accountStatus === 'active' && !hasPendingInvoice && ( + +)} +``` + +--- + +### HIGH UX Issue #5: "Manage Billing" Button Shown to Non-Stripe Users + +**Location:** `PlansAndBillingPage.tsx:468-477` + +**Current Code:** +```tsx +{availableGateways.stripe && hasActivePlan && ( + +)} +``` + +**Problem:** +- Shows "Manage Billing" if Stripe is available, even if user pays via Bank Transfer +- Bank Transfer users clicking this get error "No Stripe customer ID" + +**Fix Required:** +```tsx +{availableGateways.stripe && + hasActivePlan && + user?.account?.stripe_customer_id && + selectedPaymentMethod === 'stripe' && ( + +)} +``` + +--- + +### HIGH UX Issue #6: Credits Section Doesn't Show Pending Credit Purchases + +**Location:** `PlansAndBillingPage.tsx:689-713` + +**Problem:** If user purchased credits via bank transfer and it's `pending_approval`, they don't see this anywhere clearly. They might try to purchase again. + +**Fix Required:** Add pending credits indicator: +```tsx +{pendingCreditPayments.length > 0 && ( +
+

+ You have {pendingCreditPayments.length} credit purchase(s) pending approval +

+
+)} +``` + +--- + +### MEDIUM UX Issue #7: Invoice Status Badge Colors Inconsistent + +**Location:** `PlansAndBillingPage.tsx:817-819` + +**Current Code:** +```tsx + + {invoice.status} + +``` + +**Problem:** Only handles `paid` and everything else is `warning`. Missing: +- `draft` - Should be gray/neutral +- `void` - Should be gray +- `uncollectible` - Should be error/red +- `pending` - Warning (correct) + +**Fix Required:** +```tsx +const getInvoiceStatusTone = (status: string) => { + switch (status) { + case 'paid': return 'success'; + case 'pending': return 'warning'; + case 'void': + case 'draft': return 'neutral'; + case 'uncollectible': return 'error'; + default: return 'neutral'; + } +}; +``` + +--- + +### MEDIUM UX Issue #8: No Clear Indication of Payment Method per Invoice + +**Location:** `PlansAndBillingPage.tsx:809-849` (Invoice table) + +**Problem:** Invoice table doesn't show which payment method was used/expected. User can't tell if they need to do bank transfer or card payment. + +**Fix Required:** Add payment method column: +```tsx + + {invoice.payment_method === 'bank_transfer' ? ( + Bank + ) : invoice.payment_method === 'paypal' ? ( + PayPal + ) : ( + Card + )} + +``` + +--- + +### MEDIUM UX Issue #9: Renewal Date Shows Even When No Active Subscription + +**Location:** `PlansAndBillingPage.tsx:514-522` + +**Current Code:** +```tsx +
+ {currentSubscription?.current_period_end + ? new Date(currentSubscription.current_period_end).toLocaleDateString(...) + : '—'} +
+
Next billing
+``` + +**Problem:** Shows "—" and "Next billing" even when: +- Account is `pending_payment` (never billed yet) +- Subscription is `canceled` (won't renew) + +**Fix Required:** +```tsx +
+ {currentSubscription?.status === 'canceled' ? 'Ends on' : + currentSubscription?.status === 'pending_payment' ? 'Starts after payment' : + 'Next billing'} +
+``` + +--- + +### MEDIUM UX Issue #10: Payment Gateway Selection Not Synced With Account + +**Location:** `PlansAndBillingPage.tsx:655-686` + +**Problem:** Payment method selector in "Buy Credits" section can show options the user hasn't verified. If user signed up with bank transfer, they shouldn't see PayPal as an option until they've added it. + +**Current Logic:** Shows all `availableGateways` regardless of user's `AccountPaymentMethod` records. + +**Fix Required:** +```tsx +// Only show gateways user has verified OR is willing to add +const userCanUseGateway = (gateway: PaymentGateway) => { + const userHasMethod = userPaymentMethods.some(m => + (gateway === 'stripe' && m.type === 'stripe') || + (gateway === 'paypal' && m.type === 'paypal') || + (gateway === 'manual' && ['bank_transfer', 'local_wallet'].includes(m.type)) + ); + return availableGateways[gateway] && (userHasMethod || gateway === selectedGateway); +}; +``` + +--- + +### LOW UX Issue #11: Annual Billing Toggle Does Nothing + +**Location:** `PlansAndBillingPage.tsx:959-983` + +**Current Code:** +```tsx +const displayPrice = selectedBillingCycle === 'annual' ? (annualPrice / 12).toFixed(0) : planPrice; +``` + +**Problem:** +- Shows "Save 20%" badge for annual +- Calculates display price +- But **no annual plans exist** in database +- Clicking "Choose Plan" subscribes to monthly regardless + +**Fix Required:** Either: +1. Remove annual toggle until annual plans implemented +2. Implement annual plan variants in backend +3. Pass `billing_cycle` to `subscribeToPlan()` and handle in backend + +--- + +### LOW UX Issue #12: PayInvoiceModal Hardcodes Bank Details + +**Location:** `PayInvoiceModal.tsx:437-443` + +**Current Code:** +```tsx +

Bank: Standard Chartered Bank Pakistan

+

Account Title: IGNY8 Technologies

+

Account #: 01-2345678-01

+``` + +**Problem:** Bank details hardcoded in frontend. Should come from `PaymentMethodConfig` in backend. + +**Fix Required:** Fetch bank details from `/v1/billing/payment-configs/payment-methods/?country_code=PK&payment_method=bank_transfer` and display dynamically. + +--- + +### Account Lifecycle State Machine (Missing) + +The page doesn't follow a clear state machine. Here's what it SHOULD be: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ACCOUNT STATES │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ Payment ┌──────────┐ Payment ┌──────────┐ │ +│ │ trial │ ────────────▶ │ pending_ │ ────────────▶ │ active │ │ +│ │ │ Required │ payment │ Success │ │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ │ │ │ +│ │ │ Payment │ │ +│ │ │ Failed │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ expired │ │suspended │ ◀─────────── │ past_due │ │ +│ │ │ │ │ Auto │ │ │ +│ └──────────┘ └──────────┘ Suspend └──────────┘ │ +│ │ │ │ +│ │ Admin │ │ +│ │ Action │ │ +│ ▼ │ │ +│ ┌──────────┐ │ │ +│ │cancelled │ ◀──────────────────┘ │ +│ │ │ User Cancel │ +│ └──────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Each State Should Show:** + +| State | Plan Badge | Actions Available | Warnings | +|-------|------------|-------------------|----------| +| `trial` | "Trial (X days)" | Upgrade, Buy Credits | "Trial ends [date]" | +| `pending_payment` | "Awaiting Payment" | Pay Invoice, Change Method | "Complete payment to activate" | +| `active` | "Active" | Upgrade, Buy Credits, Cancel, Manage | None | +| `past_due` | "Payment Overdue" | Update Payment, Pay Now | "Update payment to avoid suspension" | +| `suspended` | "Suspended" | Pay to Reactivate | "Account suspended - pay to restore" | +| `cancelled` | "Cancels [date]" | Resubscribe | "Access ends [date]" | + +--- + +### Payment Options By State (Missing Restrictions) + +**Current:** All payment options shown regardless of account state. + +**Required Restrictions:** + +| Account State | Can Upgrade? | Can Buy Credits? | Can Change Plan? | Can Cancel? | +|---------------|--------------|------------------|------------------|-------------| +| `trial` | Yes | Yes | N/A | No | +| `pending_payment` | No (Pay first) | No (Pay first) | No | No | +| `active` | Yes | Yes | Yes | Yes | +| `past_due` | No (Pay first) | No | No | Yes | +| `suspended` | No | No | No | No | +| `cancelled` | Yes (Resubscribe) | No | No | No | + +--- + +### Summary of PlansAndBillingPage Fixes Needed + +| # | Issue | Severity | Effort | +|---|-------|----------|--------| +| 1 | Plan shows "Active" with unpaid invoice | CRITICAL | 30 min | +| 2 | Subscription states not reflected | CRITICAL | 1 hr | +| 3 | Upgrade available when payment pending | HIGH | 15 min | +| 4 | Cancel available for unpaid subscriptions | HIGH | 15 min | +| 5 | Manage Billing shown to non-Stripe users | HIGH | 15 min | +| 6 | No pending credit purchase indicator | HIGH | 30 min | +| 7 | Invoice status colors inconsistent | MEDIUM | 15 min | +| 8 | No payment method shown per invoice | MEDIUM | 30 min | +| 9 | Renewal date context wrong | MEDIUM | 15 min | +| 10 | Gateway selection not synced | MEDIUM | 30 min | +| 11 | Annual billing does nothing | LOW | 2 hrs | +| 12 | Bank details hardcoded | LOW | 1 hr | + +--- + +## Critical Issues (Immediate Action Required) + +### 1. PayPal Webhook Signature Not Enforced + +**Location:** `paypal_views.py:498-511` + +**Problem:** +```python +if not is_valid: + logger.warning("PayPal webhook signature verification failed") + # Optionally reject invalid signatures + # return Response({'error': 'Invalid signature'}, status=400) # COMMENTED OUT! +``` + +**Risk:** Malicious actors can craft fake webhook events to: +- Approve payments that never happened +- Cancel legitimate subscriptions +- Add credits without payment + +**Fix Required:** +```python +if not is_valid: + logger.error("PayPal webhook signature verification failed") + return Response({'error': 'Invalid signature'}, status=400) # UNCOMMENT +``` + +--- + +### 2. Stripe Webhook Not Idempotent (Double-Charge Risk) + +**Location:** `stripe_views.py:380-391` (_handle_checkout_completed) + +**Problem:** Webhook can be called multiple times for same event. No check prevents duplicate invoice/payment creation. + +**Scenario:** +1. Stripe sends webhook +2. Invoice and payment created +3. Stripe retries webhook (network timeout) +4. **Duplicate invoice and payment created** + +**Fix Required:** +```python +# At start of _handle_checkout_completed: +session_id = session.get('id') +if Payment.objects.filter(stripe_checkout_session_id=session_id).exists(): + logger.info(f"Webhook already processed for session {session_id}") + return # Already processed +``` + +--- + +### 3. PayPal Capture Order Not Idempotent + +**Location:** `paypal_views.py:261-365` (PayPalCaptureOrderView) + +**Problem:** If frontend calls `/capture-order/` twice (network timeout), payment captured twice. + +**Fix Required:** +```python +# Check if order already captured +existing = Payment.objects.filter(paypal_order_id=order_id, status='succeeded').first() +if existing: + return Response({'status': 'already_captured', 'payment_id': existing.id}) +``` + +--- + +### 4. Refund Functions Call Non-Existent Modules + +**Location:** `refund_views.py:160-208` + +**Problem:** +```python +from igny8_core.business.billing.utils.payment_gateways import get_stripe_client # DOESN'T EXIST +from igny8_core.business.billing.utils.payment_gateways import get_paypal_client # DOESN'T EXIST +``` + +**Risk:** Refunds marked as processed but **never actually charged back** to customer. + +**Fix Required:** Create the missing modules or use existing service classes: +```python +from igny8_core.business.billing.services.stripe_service import StripeService +from igny8_core.business.billing.services.paypal_service import PayPalService +``` + +--- + +### 5. Amount Validation Missing for PayPal + +**Location:** `paypal_views.py:569` + +**Problem:** +```python +amount = float(capture_result.get('amount', package.price)) # Trusts PayPal amount +``` + +**Risk:** If PayPal returns wrong amount, system processes it as correct. + +**Fix Required:** +```python +captured_amount = float(capture_result.get('amount', 0)) +expected_amount = float(package.price) +if abs(captured_amount - expected_amount) > 0.01: # Allow 1 cent tolerance + logger.error(f"Amount mismatch: captured={captured_amount}, expected={expected_amount}") + return Response({'error': 'Amount mismatch'}, status=400) +``` + +--- + +## High Priority Issues + +### 6. No Admin Dashboard for Pending Payments + +**Problem:** Admins must use Django admin to approve manual payments. + +**Missing Endpoint:** +``` +GET /v1/admin/billing/pending-payments/ - List pending approvals +POST /v1/admin/billing/payments/{id}/approve/ - Approve payment +POST /v1/admin/billing/payments/{id}/reject/ - Reject payment +``` + +**Required Implementation:** +```python +class AdminPaymentViewSet(viewsets.ModelViewSet): + permission_classes = [IsAdminUser] + + @action(detail=False, methods=['get']) + def pending(self, request): + payments = Payment.objects.filter(status='pending_approval') + return Response(PaymentSerializer(payments, many=True).data) + + @action(detail=True, methods=['post']) + def approve(self, request, pk=None): + payment = self.get_object() + PaymentService.approve_manual_payment(payment, request.user.id, request.data.get('notes')) + return Response({'status': 'approved'}) +``` + +--- + +### 7. Invoice Number Race Condition + +**Location:** `invoice_service.py:52-78` + +**Problem:** +```python +count = Invoice.objects.select_for_update().filter(...).count() +invoice_number = f"{prefix}-{count + 1:04d}" +while Invoice.objects.filter(invoice_number=invoice_number).exists(): # NOT LOCKED! + count += 1 +``` + +**Fix Required:** +```python +# Use database unique constraint + retry logic +@transaction.atomic +def generate_invoice_number(account_id, invoice_type='SUB'): + prefix = f"INV-{account_id}-{invoice_type}-{timezone.now().strftime('%Y%m')}" + for attempt in range(5): + count = Invoice.objects.filter(invoice_number__startswith=prefix).count() + invoice_number = f"{prefix}-{count + 1:04d}" + try: + # Use get_or_create with unique constraint + return invoice_number + except IntegrityError: + continue + raise ValueError("Unable to generate unique invoice number") +``` + +--- + +### 8. Browser Redirect Lost After Payment + +**Problem:** If user closes browser after Stripe payment but before redirect: +- Payment succeeds (webhook processes it) +- User doesn't know payment succeeded +- May attempt to pay again + +**Fix Required:** Add payment status check endpoint: +```python +# New endpoint +GET /v1/billing/payment-status/{session_id}/ + +# Frontend should check this on /account/plans load +const checkPaymentStatus = async (sessionId) => { + const response = await fetch(`/v1/billing/payment-status/${sessionId}/`); + if (response.data.status === 'completed') { + toast.success('Your previous payment was successful!'); + refreshUser(); + } +}; +``` + +--- + +### 9. Subscription Renewal Gets Stuck + +**Location:** `subscription_renewal.py:78-131` + +**Problem:** Status set to `pending_renewal` with no expiry or retry mechanism. + +**Fix Required:** +```python +# Add Celery task +@app.task +def check_stuck_renewals(): + """Run daily to check for stuck renewals""" + stuck = Subscription.objects.filter( + status='pending_renewal', + metadata__renewal_required_at__lt=timezone.now() - timedelta(days=7) + ) + for sub in stuck: + # Send reminder email + send_renewal_reminder(sub) + # After 14 days, suspend + if sub.metadata.get('renewal_required_at') < timezone.now() - timedelta(days=14): + sub.status = 'past_due' + sub.account.status = 'suspended' + sub.save() +``` + +--- + +### 10. Currency Exchange Rates Hardcoded + +**Location:** `currency.py:137-157` + +**Problem:** +```python +CURRENCY_MULTIPLIERS = { + 'PKR': 278.0, # STATIC - real rate changes daily! + 'INR': 83.0, + # ... +} +``` + +**Risk:** Users charged incorrect amounts over time. + +**Fix Required:** +```python +class ExchangeRateService: + CACHE_KEY = 'exchange_rates' + CACHE_TTL = 86400 # 24 hours + + @classmethod + def get_rate(cls, currency): + rates = cache.get(cls.CACHE_KEY) + if not rates: + rates = cls._fetch_from_api() + cache.set(cls.CACHE_KEY, rates, cls.CACHE_TTL) + return rates.get(currency, 1.0) + + @classmethod + def _fetch_from_api(cls): + # Use OpenExchangeRates, Fixer.io, or similar + response = requests.get('https://api.exchangerate-api.com/v4/latest/USD') + return response.json()['rates'] +``` + +--- + +## Medium Priority Issues + +### 11. No Promo Code/Discount Support + +**Missing Models:** +```python +class PromoCode(models.Model): + code = models.CharField(max_length=50, unique=True) + discount_type = models.CharField(choices=[('percent', '%'), ('fixed', '$')]) + discount_value = models.DecimalField(max_digits=10, decimal_places=2) + valid_from = models.DateTimeField() + valid_until = models.DateTimeField(null=True) + max_uses = models.IntegerField(null=True) + current_uses = models.IntegerField(default=0) + applicable_plans = models.ManyToManyField('Plan', blank=True) +``` + +--- + +### 12. No Partial Payment Support + +**Current:** User must pay full invoice amount. + +**Needed:** +- Split invoice into multiple payments +- Track partial payment progress +- Handle remaining balance + +--- + +### 13. No Dunning Management + +**Missing:** When payment fails: +- Day 1: Payment failed notification +- Day 3: Retry attempt + reminder +- Day 7: Second retry + warning +- Day 14: Account suspension warning +- Day 21: Account suspended + +--- + +### 14. No Manual Payment Reference Uniqueness + +**Location:** `models.py:487-490` + +**Fix:** +```python +manual_reference = models.CharField( + max_length=255, + blank=True, + unique=True, # ADD THIS + null=True # Allow null for non-manual payments +) +``` + +--- + +### 15. Refund Credit Deduction Race Condition + +**Location:** `refund_views.py:108-129` + +**Fix:** Use `select_for_update()`: +```python +with transaction.atomic(): + account = Account.objects.select_for_update().get(id=payment.account_id) + if account.credit_balance >= credits_to_deduct: + account.credit_balance -= credits_to_deduct + account.save() +``` + +--- + +### 16. Invoice Total Calculation Silent Failure + +**Location:** `models.py:439-448` + +**Problem:** +```python +except Exception: + pass # SILENT FAILURE! +``` + +**Fix:** +```python +except Exception as e: + logger.error(f"Invalid line item in invoice {self.id}: {item}, error: {e}") + raise ValueError(f"Invalid invoice line item: {item}") +``` + +--- + +### 17. No Webhook Event Storage + +**Missing:** All incoming webhooks should be stored for: +- Audit trail +- Replay on failure +- Debugging + +**Add Model:** +```python +class WebhookEvent(models.Model): + event_id = models.CharField(max_length=255, unique=True) + provider = models.CharField(max_length=20) # stripe, paypal + event_type = models.CharField(max_length=100) + payload = models.JSONField() + processed = models.BooleanField(default=False) + processed_at = models.DateTimeField(null=True) + error_message = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) +``` + +--- + +### 18. No Payment Audit Trail for Rejections + +**Missing Fields in Payment model:** +```python +rejected_by = models.ForeignKey('User', null=True, related_name='rejected_payments') +rejected_at = models.DateTimeField(null=True) +rejection_reason = models.TextField(blank=True) +``` + +--- + +## Recommendations Summary + +### Immediate (This Week) + +| # | Issue | Effort | Impact | +|---|-------|--------|--------| +| 1 | Enable PayPal webhook signature verification | 5 min | CRITICAL | +| 2 | Add Stripe webhook idempotency check | 30 min | CRITICAL | +| 3 | Add PayPal capture idempotency check | 30 min | CRITICAL | +| 4 | Fix refund module imports | 1 hr | CRITICAL | +| 5 | Add PayPal amount validation | 30 min | CRITICAL | + +### Short-Term (This Month) + +| # | Issue | Effort | Impact | +|---|-------|--------|--------| +| 6 | Build admin pending payments dashboard | 1 day | HIGH | +| 7 | Fix invoice number race condition | 2 hrs | HIGH | +| 8 | Add payment status check endpoint | 2 hrs | HIGH | +| 9 | Fix stuck renewal subscriptions | 1 day | HIGH | +| 10 | Implement dynamic currency rates | 1 day | HIGH | + +### Medium-Term (This Quarter) + +| # | Issue | Effort | Impact | +|---|-------|--------|--------| +| 11 | Promo code system | 3 days | MEDIUM | +| 12 | Partial payment support | 2 days | MEDIUM | +| 13 | Dunning management | 2 days | MEDIUM | +| 14 | Webhook event storage | 1 day | MEDIUM | +| 15 | Payment audit trail | 1 day | MEDIUM | + +--- + +## Architecture Assessment + +### What's Good + +1. **Clean separation** between frontend entry points, services, and backend +2. **Country-based logic** correctly isolates Pakistan users from PayPal +3. **Multi-gateway support** (Stripe, PayPal, Manual) well architected +4. **Service layer abstraction** (`StripeService`, `PayPalService`, `PaymentService`) +5. **Invoice and payment tracking** comprehensive +6. **Webhook handlers** exist for both gateways + +### What Needs Improvement + +1. **Idempotency** - Critical for payment processing +2. **Transaction safety** - Need more `@transaction.atomic()` and `select_for_update()` +3. **Observability** - No webhook event storage, limited metrics +4. **Admin tooling** - Manual payments need proper dashboard +5. **Error handling** - Too many silent failures +6. **Feature gaps** - No promo codes, partial payments, dunning + +--- + +## Final Assessment + +| Area | Rating | Notes | +|------|--------|-------| +| Documentation Accuracy | A | Matches codebase | +| Security | C | Webhook verification gaps | +| Reliability | C | Idempotency issues | +| Completeness | B | Core features present | +| Admin Experience | D | No proper dashboard | +| User Experience | B | Good flows, missing status checks | +| Code Quality | B | Good structure, some silent failures | + +**Overall Grade: C+** + +The payment system is functional but has critical security and reliability gaps that must be addressed before scaling. The architecture is sound, but implementation details need hardening. + +--- + +## Quick Wins (Can Do Today) + +1. Uncomment PayPal webhook signature rejection (5 min) +2. Add `@transaction.atomic()` to all payment handlers (30 min) +3. Add duplicate check before creating payments (30 min) +4. Add unique constraint to `manual_reference` (migration) +5. Remove silent `except: pass` blocks (30 min) + +--- + +*Report generated by deep audit of IGNY8 payment system codebase.* diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md new file mode 100644 index 00000000..1e5298b2 --- /dev/null +++ b/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md @@ -0,0 +1,1350 @@ +# Payment System Complete Refactor Plan + +> **Version:** 1.0 +> **Date:** January 7, 2026 +> **Status:** In Progress - Phase 1 Complete ✅ + +--- + +## Executive Summary + +This plan consolidates all audit findings and architectural discussions into a comprehensive refactor that will: + +1. **Simplify signup flow** - Remove payment gateway redirect from signup +2. **Unify payment experience** - Single payment interface in `/account/plans` +3. **Fix all critical security issues** - Webhook verification, idempotency +4. **Clean up dead code** - Remove `/pk` variant, country detection API, unused fields +5. **Implement proper state handling** - Account lifecycle properly reflected in UI + +--- + +## Architecture Changes + +### Before (Current) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CURRENT FLOW │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ /signup OR /signup/pk │ +│ │ │ +│ ├── Country detection API call │ +│ ├── Load payment methods API call │ +│ ├── Show payment method selection │ +│ │ │ +│ └── On Submit: │ +│ ├── Stripe → Redirect to Stripe → Return to /plans │ +│ ├── PayPal → Redirect to PayPal → Return to /plans │ +│ └── Bank → Navigate to /plans (show banner) │ +│ │ +│ PROBLEMS: │ +│ - Two signup routes (/signup and /signup/pk) │ +│ - Country detection API complexity │ +│ - Payment gateway redirect from signup │ +│ - User loses context if redirect fails │ +│ - Duplicate payment logic (signup + plans page) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### After (New) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ NEW FLOW │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ /signup (SINGLE ROUTE) │ +│ │ │ +│ ├── Country dropdown (auto-detect default using IP API) │ +│ ├── Plan selection │ +│ ├── NO payment method selection │ +│ │ │ +│ └── On Submit: │ +│ └── Create account (pending_payment) │ +│ └── Navigate to /account/plans │ +│ │ +│ /account/plans │ +│ │ │ +│ ├── IF new user (never paid): │ +│ │ └── Show PendingPaymentView (full page) │ +│ │ - Invoice details │ +│ │ - Payment method selection (based on country) │ +│ │ - Inline payment form │ +│ │ │ +│ ├── IF existing user (has paid before): │ +│ │ └── Show ActiveSubscriptionView │ +│ │ - Current plan details │ +│ │ - Credit balance │ +│ │ - Buy credits (modal) │ +│ │ - Billing history │ +│ │ - IF pending invoice: Show banner + Pay modal │ +│ │ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Phase 1: Backend Cleanup & Security Fixes ✅ + +### 1.1 Fix Critical Security Issues ✅ + +#### 1.1.1 Enable PayPal Webhook Signature Verification ✅ + +**File:** `backend/igny8_core/business/billing/views/paypal_views.py` + +```python +# Line ~510 - UNCOMMENT the rejection +if not is_valid: + logger.error("PayPal webhook signature verification failed") + return Response({'error': 'Invalid signature'}, status=400) # UNCOMMENT THIS +``` + +#### 1.1.2 Add Stripe Webhook Idempotency ✅ + +**File:** `backend/igny8_core/business/billing/views/stripe_views.py` + +```python +# Add at start of _handle_checkout_completed (around line 380): +@transaction.atomic +def _handle_checkout_completed(session): + session_id = session.get('id') + + # IDEMPOTENCY CHECK + if Payment.objects.filter( + metadata__stripe_checkout_session_id=session_id + ).exists(): + logger.info(f"Webhook already processed for session {session_id}") + return + + # ... rest of handler +``` + +#### 1.1.3 Add PayPal Capture Idempotency ✅ + +**File:** `backend/igny8_core/business/billing/views/paypal_views.py` + +```python +# Add at start of PayPalCaptureOrderView.post (around line 270): +def post(self, request): + order_id = request.data.get('order_id') + + # IDEMPOTENCY CHECK + existing = Payment.objects.filter( + paypal_order_id=order_id, + status='succeeded' + ).first() + if existing: + return Response({ + 'status': 'already_captured', + 'payment_id': existing.id, + 'message': 'This order has already been captured' + }) + + # ... rest of handler +``` + +#### 1.1.4 Add PayPal Amount Validation ✅ + +**File:** `backend/igny8_core/business/billing/views/paypal_views.py` + +```python +# In _process_credit_purchase (around line 570): +captured_amount = Decimal(str(capture_result.get('amount', '0'))) +expected_amount = Decimal(str(package.price)) + +if abs(captured_amount - expected_amount) > Decimal('0.01'): + logger.error(f"Amount mismatch: captured={captured_amount}, expected={expected_amount}") + return Response({ + 'error': 'Payment amount does not match expected amount', + 'captured': str(captured_amount), + 'expected': str(expected_amount) + }, status=400) +``` + +#### 1.1.5 Fix Refund Module Imports ✅ + +**File:** `backend/igny8_core/business/billing/views/refund_views.py` + +```python +# Replace non-existent imports (around line 160): +# FROM: +from igny8_core.business.billing.utils.payment_gateways import get_stripe_client +from igny8_core.business.billing.utils.payment_gateways import get_paypal_client + +# TO: +from igny8_core.business.billing.services.stripe_service import StripeService +from igny8_core.business.billing.services.paypal_service import PayPalService + +# Then use: +stripe_service = StripeService() +stripe_service.create_refund(payment_intent_id, amount) +``` + +--- + +### 1.2 Simplify Registration Serializer ✅ + +**File:** `backend/igny8_core/auth/serializers.py` + +**REMOVE from RegisterSerializer.create():** +- Stripe checkout session creation +- PayPal order creation +- `checkout_url` return +- `checkout_session_id` return +- `paypal_order_id` return + +**KEEP:** +- User creation +- Account creation with `status='pending_payment'` +- Subscription creation with `status='pending_payment'` +- Invoice creation +- AccountPaymentMethod creation (just store type, don't verify) + +**NEW create() method (simplified):** + +```python +def create(self, validated_data): + # Extract fields + plan_slug = validated_data.pop('plan_slug', 'free') + billing_country = validated_data.pop('billing_country', '') + + # Create user + user = User.objects.create_user(...) + + # Get plan + plan = Plan.objects.get(slug=plan_slug, is_active=True) + is_paid_plan = plan.price > 0 + + # Create account + account = Account.objects.create( + name=validated_data.get('account_name', user.username), + owner=user, + plan=plan, + credits=0 if is_paid_plan else plan.included_credits, + status='pending_payment' if is_paid_plan else 'active', + billing_country=billing_country, + ) + + if is_paid_plan: + # Create subscription + subscription = Subscription.objects.create( + account=account, + plan=plan, + status='pending_payment', + current_period_start=timezone.now(), + current_period_end=timezone.now() + timedelta(days=30), + ) + + # Create invoice + InvoiceService.create_subscription_invoice( + subscription=subscription, + payment_method=None, # Will be set when user pays + ) + + return user # NO checkout_url returned +``` + +**File:** `backend/igny8_core/auth/views.py` + +**REMOVE from RegisterView.post():** +- Stripe checkout session creation +- PayPal order creation +- `checkout_url` in response + +**NEW response (simplified):** + +```python +def post(self, request): + serializer = RegisterSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + # Generate tokens + tokens = get_tokens_for_user(user) + + return Response({ + 'success': True, + 'user': UserSerializer(user).data, + 'tokens': tokens, + # NO checkout_url - user will pay on /account/plans + }) +``` + +--- + +### 1.3 Add Country List Endpoint ✅ + +**File:** `backend/igny8_core/auth/urls.py` (NEW) + +```python +class CountryListView(APIView): + """Returns list of countries for signup dropdown""" + permission_classes = [] # Public endpoint + + def get(self, request): + countries = [ + {'code': 'US', 'name': 'United States'}, + {'code': 'GB', 'name': 'United Kingdom'}, + {'code': 'CA', 'name': 'Canada'}, + {'code': 'AU', 'name': 'Australia'}, + {'code': 'PK', 'name': 'Pakistan'}, + {'code': 'IN', 'name': 'India'}, + # ... full list + ] + return Response({'countries': countries}) +``` + +**File:** `backend/igny8_core/auth/urls.py` + +```python +path('countries/', CountryListView.as_view(), name='country-list'), +``` + +--- + +### 1.4 Add WebhookEvent Model for Audit Trail ✅ + +**File:** `backend/igny8_core/business/billing/models.py` (ADD) + +```python +class WebhookEvent(models.Model): + """Store all incoming webhook events for audit and replay""" + event_id = models.CharField(max_length=255, unique=True) + provider = models.CharField(max_length=20) # 'stripe' or 'paypal' + event_type = models.CharField(max_length=100) + payload = models.JSONField() + processed = models.BooleanField(default=False) + processed_at = models.DateTimeField(null=True, blank=True) + error_message = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=['provider', 'event_type']), + models.Index(fields=['processed']), + ] +``` + +--- + +### 1.5 Add Invoice Unique Constraint ✅ + +**Migration:** Add unique constraint to invoice_number + +```python +# In Invoice model +invoice_number = models.CharField(max_length=50, unique=True) # Already has unique=True +``` + +**Update invoice_service.py** to handle IntegrityError: + +```python +from django.db import IntegrityError + +@transaction.atomic +def generate_invoice_number(account_id, invoice_type='SUB'): + prefix = f"INV-{account_id}-{invoice_type}-{timezone.now().strftime('%Y%m')}" + + for attempt in range(5): + count = Invoice.objects.filter( + invoice_number__startswith=prefix + ).count() + invoice_number = f"{prefix}-{count + 1:04d}" + + # Check if exists (within transaction) + if not Invoice.objects.filter(invoice_number=invoice_number).exists(): + return invoice_number + + raise ValueError(f"Unable to generate unique invoice number after 5 attempts") +``` + +--- + +### 1.6 Add Manual Reference Uniqueness ✅ + +**Migration:** + +```python +class Migration(migrations.Migration): + operations = [ + migrations.AlterField( + model_name='payment', + name='manual_reference', + field=models.CharField( + max_length=255, + blank=True, + null=True, + unique=True, # ADD UNIQUE + ), + ), + ] +``` + +--- + +## Phase 2: Frontend Cleanup ✅ + +### 2.1 Simplify SignUpFormUnified.tsx ✅ + +**File:** `frontend/src/components/auth/SignUpFormUnified.tsx` + +**REMOVE:** ✅ +- Payment method selection UI +- Payment method loading from API (`/v1/billing/payment-configs/payment-methods/`) +- Stripe checkout redirect logic +- PayPal redirect logic +- `isPakistanSignup` variant logic +- All payment gateway-related state + +**KEEP:** ✅ +- Email, password, name fields +- Plan selection +- Country dropdown (NEW - replaces detection) + +**ADD:** ✅ +- Country dropdown with auto-detection default +- Simplified submit that only creates account + +**NEW component structure:** + +```tsx +export default function SignUpFormUnified() { + // State + const [formData, setFormData] = useState({ + email: '', + password: '', + username: '', + first_name: '', + last_name: '', + account_name: '', + billing_country: '', // From dropdown + }); + const [selectedPlan, setSelectedPlan] = useState(null); + const [countries, setCountries] = useState([]); + const [detectedCountry, setDetectedCountry] = useState('US'); + + // Load countries and detect user's country + useEffect(() => { + loadCountries(); + detectUserCountry(); + }, []); + + const loadCountries = async () => { + const response = await fetch(`${API_BASE_URL}/v1/auth/countries/`); + const data = await response.json(); + setCountries(data.countries); + }; + + const detectUserCountry = async () => { + try { + // Use IP-based detection (optional - fallback to US) + const response = await fetch('https://ipapi.co/json/'); + const data = await response.json(); + setDetectedCountry(data.country_code || 'US'); + setFormData(prev => ({ ...prev, billing_country: data.country_code || 'US' })); + } catch { + setDetectedCountry('US'); + setFormData(prev => ({ ...prev, billing_country: 'US' })); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + // Register - NO payment redirect + const result = await register({ + ...formData, + plan_slug: selectedPlan?.slug || 'free', + }); + + // Always navigate to plans page + // For paid plans: will show PendingPaymentView + // For free plan: will show ActiveSubscriptionView + if (selectedPlan && selectedPlan.price > 0) { + navigate('/account/plans'); + toast.info('Complete your payment to activate your plan'); + } else { + navigate('/dashboard'); + toast.success('Welcome to IGNY8!'); + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Email, Password, Name fields */} + + {/* Country Dropdown - NEW */} + + {countries.map((country) => ( + + ))} + + )} +

+ Your country determines available payment methods +

+
@@ -620,7 +462,7 @@ export default function SignUpFormUnified({ Creating your account... ) : isPaidPlan ? ( - 'Create Account & Continue to Payment' + 'Create Account' ) : ( 'Start Free Trial' )} diff --git a/frontend/src/components/billing/BankTransferForm.tsx b/frontend/src/components/billing/BankTransferForm.tsx new file mode 100644 index 00000000..9af3ff1f --- /dev/null +++ b/frontend/src/components/billing/BankTransferForm.tsx @@ -0,0 +1,355 @@ +/** + * BankTransferForm - Component for submitting bank transfer proof + * + * Used when user selects bank transfer payment method (primarily for Pakistan) + * Shows bank details to transfer to and form to submit payment proof + */ + +import { useState, useEffect } from 'react'; +import { + Building2Icon, + CopyIcon, + CheckCircleIcon, + Loader2Icon, + AlertCircleIcon, + FileTextIcon, +} from '../../icons'; +import { Card } from '../ui/card'; +import Button from '../ui/button/Button'; +import Label from '../form/Label'; +import Input from '../form/input/InputField'; +import TextArea from '../form/input/TextArea'; +import { useToast } from '../ui/toast/ToastContainer'; +import { + Invoice, + submitManualPayment, + getAvailablePaymentMethods, +} from '../../services/billing.api'; + +interface BankDetails { + bank_name: string; + account_title: string; + account_number: string; + iban?: string; + swift_code?: string; +} + +interface BankTransferFormProps { + invoice: Invoice; + onSuccess: () => void; + onCancel: () => void; +} + +export default function BankTransferForm({ + invoice, + onSuccess, + onCancel, +}: BankTransferFormProps) { + const toast = useToast(); + const [loading, setLoading] = useState(false); + const [bankDetailsLoading, setBankDetailsLoading] = useState(true); + const [bankDetails, setBankDetails] = useState(null); + const [copiedField, setCopiedField] = useState(null); + + const [formData, setFormData] = useState({ + reference: '', + notes: '', + }); + + // Load bank details from backend + useEffect(() => { + const loadBankDetails = async () => { + setBankDetailsLoading(true); + try { + const { results } = await getAvailablePaymentMethods(); + // Find bank_transfer method config + const bankMethod = results.find( + (m) => m.type === 'bank_transfer' && m.is_enabled + ); + + // Cast to any to access extended bank_details properties + // Backend may return additional fields not in the TypeScript type + const details = bankMethod?.bank_details as any; + + if (details) { + setBankDetails({ + bank_name: details.bank_name || 'Bank ABC', + account_title: details.account_title || details.account_name || 'IGNY8', + account_number: details.account_number || '', + iban: details.iban, + swift_code: details.swift_code, + }); + } else { + // Fallback hardcoded details - should be replaced with backend config + setBankDetails({ + bank_name: 'MCB Bank Limited', + account_title: 'IGNY8 Technologies', + account_number: '0000123456789', + iban: 'PK00MUCB0000000123456789', + swift_code: 'MUCBPKKAXXX', + }); + } + } catch (error) { + console.error('Failed to load bank details:', error); + // Use fallback + setBankDetails({ + bank_name: 'MCB Bank Limited', + account_title: 'IGNY8 Technologies', + account_number: '0000123456789', + iban: 'PK00MUCB0000000123456789', + swift_code: 'MUCBPKKAXXX', + }); + } finally { + setBankDetailsLoading(false); + } + }; + + loadBankDetails(); + }, []); + + const copyToClipboard = async (value: string, field: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedField(field); + toast?.success?.(`${field} copied to clipboard`); + setTimeout(() => setCopiedField(null), 2000); + } catch { + toast?.error?.('Failed to copy'); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.reference.trim()) { + toast?.error?.('Please enter your transaction reference'); + return; + } + + setLoading(true); + try { + await submitManualPayment({ + invoice_id: invoice.id, + payment_method: 'bank_transfer', + amount: invoice.total_amount || invoice.total || '0', + currency: invoice.currency, + reference: formData.reference.trim(), + notes: formData.notes.trim(), + }); + + toast?.success?.('Payment submitted successfully! We will verify it within 24 hours.'); + onSuccess(); + } catch (error: any) { + console.error('Payment submission failed:', error); + toast?.error?.(error.message || 'Failed to submit payment'); + } finally { + setLoading(false); + } + }; + + if (bankDetailsLoading) { + return ( + +
+ + Loading bank details... +
+
+ ); + } + + if (!bankDetails) { + return ( + +
+ + + Bank details not available. Please contact support. + +
+
+ ); + } + + return ( + +
+
+ +
+
+

+ Bank Transfer +

+

+ Transfer to the account below and submit your reference +

+
+
+ + {/* Bank Details */} +
+

Transfer Details

+ +
+
+
+

Bank

+

{bankDetails.bank_name}

+
+
+ +
+
+

Account Title

+

{bankDetails.account_title}

+
+ +
+ +
+
+

Account Number

+

{bankDetails.account_number}

+
+ +
+ + {bankDetails.iban && ( +
+
+

IBAN

+

{bankDetails.iban}

+
+ +
+ )} +
+ + {/* Reference to include */} +
+
+
+

Payment Reference (Include in transfer)

+

{invoice.invoice_number}

+
+ +
+
+ + {/* Amount */} +
+ Amount to Transfer + + {invoice.currency === 'PKR' ? 'PKR ' : '$'}{invoice.total_amount || invoice.total} + +
+
+ + {/* Submit Form */} + +
+ + setFormData({ ...formData, reference: e.target.value })} + /> +

+ Enter the transaction ID or reference from your bank receipt +

+
+ +
+ +