# 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 */}