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
+
+ You have {pendingCreditPayments.length} credit purchase(s) pending approval +
+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+ Your {invoice.subscription?.plan?.name} plan is ready to activate +
+Invoice
+#{invoice.invoice_number}
+Amount Due
++ {invoice.currency} {invoice.total_amount} +
++ Pay securely with your credit or debit card via Stripe +
+ ++ Pay securely with your PayPal account +
+ +Payment Required
+Complete your payment to continue using all features
+Credit Balance
+{user?.account?.credits?.toLocaleString()}
+Bank: {bankDetails.bank_name}
+Account Title: {bankDetails.account_title}
+Account #: {bankDetails.account_number}
+IBAN: {bankDetails.iban}
+Reference: {invoiceNumber}
+Amount: {amount}
+No payment methods available for your region
-- 🇵🇰 Pakistan - Bank transfer available -
- )} -+ Your country determines available payment methods +
++ Transfer to the account below and submit your reference +
+Bank
+{bankDetails.bank_name}
+Account Title
+{bankDetails.account_title}
+Account Number
+{bankDetails.account_number}
+IBAN
+{bankDetails.iban}
+Payment Reference (Include in transfer)
+{invoice.invoice_number}
++ After submitting, our team will verify your payment within 24 hours. + You'll receive an email confirmation once approved. +
++ Complete your payment to activate your {planName} plan +
++ {selectedGateway === 'manual' + ? 'You will receive bank details to complete your transfer' + : 'You will be redirected to complete payment securely' + } +
+
-
-
-