diff --git a/docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md similarity index 100% rename from docs/plans/DJANGO-ADMIN-ACCESS-GUIDE.md rename to docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md diff --git a/docs/plans/THIRD-PARTY-INTEGRATIONS-PLAN.md b/docs/plans/implemented/THIRD-PARTY-INTEGRATIONS-PLAN.md similarity index 100% rename from docs/plans/THIRD-PARTY-INTEGRATIONS-PLAN.md rename to docs/plans/implemented/THIRD-PARTY-INTEGRATIONS-PLAN.md diff --git a/docs/plans/payment-refactor-plan.md b/docs/plans/payment-refactor-plan.md new file mode 100644 index 00000000..cbda4229 --- /dev/null +++ b/docs/plans/payment-refactor-plan.md @@ -0,0 +1,2365 @@ +# 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 */} +