Files
igny8/docs/plans/payment-refactor-plan.md
IGNY8 VPS (Salman) 7da3334c03 Reorg docs
2026-01-08 00:58:28 +00:00

2366 lines
76 KiB
Markdown

# 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<Plan | null>(null);
const [countries, setCountries] = useState<Country[]>([]);
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 (
<form onSubmit={handleSubmit}>
{/* Email, Password, Name fields */}
{/* Country Dropdown - NEW */}
<Select
label="Country"
value={formData.billing_country}
onChange={(value) => setFormData({ ...formData, billing_country: value })}
options={countries.map(c => ({ value: c.code, label: c.name }))}
/>
{/* Plan Selection */}
<PlanSelector
plans={plans}
selectedPlan={selectedPlan}
onSelect={setSelectedPlan}
/>
{/* Submit Button - Different text based on plan */}
<Button type="submit" loading={loading}>
{selectedPlan?.price > 0 ? 'Continue to Payment' : 'Create Free Account'}
</Button>
</form>
);
}
```
---
### 2.2 Remove /signup/pk Route ✅
**File:** `frontend/src/routes/index.tsx` (or equivalent)
**REMOVE:**
```tsx
// DELETE THIS ROUTE
<Route path="/signup/pk" element={<SignupPage isPakistan />} />
```
**KEEP only:**
```tsx
<Route path="/signup" element={<SignupPage />} />
```
---
### 2.3 Remove Country Detection API Usage ✅
**Files to update:**
- `frontend/src/components/auth/SignUpFormUnified.tsx` - Remove ipapi.co calls for complex detection
- `frontend/src/services/billing.api.ts` - Remove `detectCountry()` function if exists
**NEW approach:** Simple IP-based detection for dropdown default only: ✅
```tsx
// Simple one-liner for default selection
const detectCountry = async () => {
try {
const res = await fetch('https://ipapi.co/country_code/');
return await res.text();
} catch {
return 'US';
}
};
```
---
### 2.4 Refactor PlansAndBillingPage.tsx ✅
**File:** `frontend/src/pages/account/PlansAndBillingPage.tsx`
**NEW structure:**
```tsx
export default function PlansAndBillingPage() {
const { user, refreshUser } = useAuthStore();
const [loading, setLoading] = useState(true);
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const accountStatus = user?.account?.status;
const billingCountry = user?.account?.billing_country;
// Determine if user has ever paid
const hasEverPaid = payments.some(p => p.status === 'succeeded');
// Determine view mode
const isNewUserPendingPayment = accountStatus === 'pending_payment' && !hasEverPaid;
useEffect(() => {
loadBillingData();
}, []);
if (loading) return <LoadingSpinner />;
// NEW USER - Show full-page payment view
if (isNewUserPendingPayment) {
return (
<PendingPaymentView
invoice={invoices.find(i => i.status === 'pending')}
userCountry={billingCountry}
onPaymentSuccess={() => {
refreshUser();
loadBillingData();
}}
/>
);
}
// EXISTING USER - Show normal billing dashboard
return (
<ActiveSubscriptionView
user={user}
invoices={invoices}
payments={payments}
hasPendingInvoice={invoices.some(i => i.status === 'pending')}
onRefresh={loadBillingData}
/>
);
}
```
---
### 2.5 Create PendingPaymentView Component ✅
**File:** `frontend/src/components/billing/PendingPaymentView.tsx` (NEW) ✅
```tsx
interface PendingPaymentViewProps {
invoice: Invoice | null;
userCountry: string;
onPaymentSuccess: () => void;
}
export default function PendingPaymentView({
invoice,
userCountry,
onPaymentSuccess,
}: PendingPaymentViewProps) {
const isPakistan = userCountry?.toUpperCase() === 'PK';
// Payment methods based on country
const availableMethods: PaymentMethod[] = isPakistan
? ['stripe', 'bank_transfer']
: ['stripe', 'paypal'];
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(availableMethods[0]);
const [loading, setLoading] = useState(false);
const [bankDetails, setBankDetails] = useState<BankDetails | null>(null);
// Load bank details for PK users
useEffect(() => {
if (isPakistan) {
loadBankDetails();
}
}, [isPakistan]);
const loadBankDetails = async () => {
const response = await fetch(
`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/?country_code=PK&payment_method=bank_transfer`
);
const data = await response.json();
if (data.results?.length > 0) {
setBankDetails(data.results[0]);
}
};
const handleStripePayment = async () => {
setLoading(true);
try {
const result = await subscribeToPlan(invoice.subscription.plan.slug, '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;
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const handlePayPalPayment = async () => {
setLoading(true);
try {
const result = await subscribeToPlan(invoice.subscription.plan.slug, 'paypal', {
return_url: `${window.location.origin}/account/plans?paypal=success`,
cancel_url: `${window.location.origin}/account/plans?paypal=cancel`,
});
window.location.href = result.redirect_url;
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const handleBankTransferSubmit = async (formData: BankTransferFormData) => {
setLoading(true);
try {
await submitManualPayment({
invoice_id: invoice.id,
payment_method: 'bank_transfer',
amount: invoice.total_amount,
manual_reference: formData.reference,
manual_notes: formData.notes,
proof_url: formData.proofUrl,
});
toast.success('Payment submitted for approval');
onPaymentSuccess();
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
if (!invoice) {
return <div>No pending invoice found</div>;
}
return (
<div className="max-w-2xl mx-auto py-12 px-4">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Complete Your Subscription
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Your {invoice.subscription?.plan?.name} plan is ready to activate
</p>
</div>
{/* Invoice Summary Card */}
<Card className="mb-6">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-500">Invoice</p>
<p className="font-medium">#{invoice.invoice_number}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Amount Due</p>
<p className="text-2xl font-bold">
{invoice.currency} {invoice.total_amount}
</p>
</div>
</div>
<div className="mt-4 pt-4 border-t">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Plan</span>
<span>{invoice.subscription?.plan?.name}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-500">Billing Period</span>
<span>Monthly</span>
</div>
</div>
</Card>
{/* Payment Method Selection */}
<Card className="mb-6">
<h3 className="font-semibold mb-4">Select Payment Method</h3>
<div className="flex gap-3">
{availableMethods.map((method) => (
<button
key={method}
onClick={() => setSelectedMethod(method)}
className={`flex-1 p-4 rounded-lg border-2 transition-all ${
selectedMethod === method
? 'border-brand-500 bg-brand-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{method === 'stripe' && <CreditCardIcon className="w-6 h-6 mx-auto mb-2" />}
{method === 'paypal' && <WalletIcon className="w-6 h-6 mx-auto mb-2" />}
{method === 'bank_transfer' && <Building2Icon className="w-6 h-6 mx-auto mb-2" />}
<span className="block text-sm font-medium">
{method === 'stripe' && 'Credit/Debit Card'}
{method === 'paypal' && 'PayPal'}
{method === 'bank_transfer' && 'Bank Transfer'}
</span>
</button>
))}
</div>
</Card>
{/* Payment Form */}
<Card>
{selectedMethod === 'stripe' && (
<div className="text-center py-6">
<CreditCardIcon className="w-12 h-12 mx-auto text-brand-500 mb-4" />
<p className="text-gray-600 mb-4">
Pay securely with your credit or debit card via Stripe
</p>
<Button
variant="primary"
onClick={handleStripePayment}
loading={loading}
className="w-full"
>
Pay {invoice.currency} {invoice.total_amount}
</Button>
</div>
)}
{selectedMethod === 'paypal' && (
<div className="text-center py-6">
<WalletIcon className="w-12 h-12 mx-auto text-[#0070ba] mb-4" />
<p className="text-gray-600 mb-4">
Pay securely with your PayPal account
</p>
<Button
variant="primary"
onClick={handlePayPalPayment}
loading={loading}
className="w-full bg-[#0070ba] hover:bg-[#005ea6]"
>
Pay with PayPal
</Button>
</div>
)}
{selectedMethod === 'bank_transfer' && (
<BankTransferForm
bankDetails={bankDetails}
invoiceNumber={invoice.invoice_number}
amount={`${invoice.currency} ${invoice.total_amount}`}
onSubmit={handleBankTransferSubmit}
loading={loading}
/>
)}
</Card>
{/* Change Plan Link */}
<div className="mt-6 text-center">
<button
onClick={() => setShowPlanSelector(true)}
className="text-sm text-gray-500 hover:text-gray-700"
>
Need a different plan?
</button>
</div>
</div>
);
}
```
---
### 2.6 Create ActiveSubscriptionView Component
**Status:** Existing PlansAndBillingPage.tsx serves this purpose - no separate component needed.
The current implementation now conditionally renders PendingPaymentView for new users,
and the full billing dashboard (effectively "ActiveSubscriptionView") for existing users.
**File:** `frontend/src/pages/account/views/ActiveSubscriptionView.tsx` (NEW)
This is the cleaned-up version of current PlansAndBillingPage with:
- Proper state handling
- Correct badge colors
- Disabled buttons based on state
- Payment modal only for credits and overdue invoices
```tsx
export default function ActiveSubscriptionView({
user,
invoices,
payments,
hasPendingInvoice,
onRefresh,
}: ActiveSubscriptionViewProps) {
const accountStatus = user?.account?.status;
const subscription = user?.account?.subscription;
// Get subscription display
const getSubscriptionDisplay = () => {
if (!subscription) return { label: 'No Plan', tone: 'error' as const };
switch (subscription.status) {
case 'active':
return hasPendingInvoice
? { label: 'Payment Due', tone: 'warning' as const }
: { label: 'Active', tone: 'success' as const };
case 'pending_payment':
return { label: 'Awaiting Payment', tone: 'warning' as const };
case 'past_due':
return { label: 'Payment Overdue', tone: 'error' as const };
case 'canceled':
return { label: 'Cancels Soon', tone: 'warning' as const };
default:
return { label: subscription.status, tone: 'neutral' as const };
}
};
const statusDisplay = getSubscriptionDisplay();
// Can user perform actions?
const canUpgrade = accountStatus === 'active' && !hasPendingInvoice;
const canBuyCredits = accountStatus === 'active' && !hasPendingInvoice;
const canCancel = accountStatus === 'active' && !hasPendingInvoice;
const canManageBilling = user?.account?.stripe_customer_id && canUpgrade;
return (
<div className="space-y-6">
{/* Pending Payment Banner for existing users */}
{hasPendingInvoice && (
<Alert variant="warning">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Payment Required</p>
<p className="text-sm">Complete your payment to continue using all features</p>
</div>
<Button
variant="primary"
size="sm"
onClick={() => setShowPaymentModal(true)}
>
Pay Now
</Button>
</div>
</Alert>
)}
{/* Current Plan Card */}
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{user?.account?.plan?.name}</h2>
<Badge variant="solid" tone={statusDisplay.tone}>
{statusDisplay.label}
</Badge>
</div>
<div className="flex gap-2">
{canManageBilling && (
<Button variant="outline" onClick={handleManageBilling}>
Manage Billing
</Button>
)}
{canUpgrade && (
<Button variant="primary" onClick={() => setShowUpgradeModal(true)}>
Upgrade
</Button>
)}
</div>
</div>
</Card>
{/* Credits Card */}
<Card>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Credit Balance</p>
<p className="text-2xl font-bold">{user?.account?.credits?.toLocaleString()}</p>
</div>
{canBuyCredits && (
<Button variant="outline" onClick={() => setShowCreditsModal(true)}>
Buy Credits
</Button>
)}
</div>
</Card>
{/* Billing History */}
<Card>
<h3 className="font-semibold mb-4">Billing History</h3>
<InvoiceTable invoices={invoices} />
</Card>
{/* Cancel Subscription - only for active paid users */}
{canCancel && subscription && (
<div className="text-center">
<button
onClick={() => setShowCancelModal(true)}
className="text-sm text-gray-500 hover:text-error-500"
>
Cancel Subscription
</button>
</div>
)}
{/* Modals */}
<PayInvoiceModal
isOpen={showPaymentModal}
onClose={() => setShowPaymentModal(false)}
invoice={invoices.find(i => i.status === 'pending')}
userCountry={user?.account?.billing_country}
onSuccess={onRefresh}
/>
<BuyCreditsModal
isOpen={showCreditsModal}
onClose={() => setShowCreditsModal(false)}
userCountry={user?.account?.billing_country}
onSuccess={onRefresh}
/>
</div>
);
}
```
---
### 2.7 Update BankTransferForm to Load Details from Backend ✅
**File:** `frontend/src/components/billing/BankTransferForm.tsx`
**REMOVE:** Hardcoded bank details ✅
**ADD:** Props for bank details from parent: ✅
```tsx
interface BankTransferFormProps {
bankDetails: {
bank_name: string;
account_title: string;
account_number: string;
iban: string;
swift_code: string;
} | null;
invoiceNumber: string;
amount: string;
onSubmit: (data: BankTransferFormData) => void;
loading: boolean;
}
export default function BankTransferForm({
bankDetails,
invoiceNumber,
amount,
onSubmit,
loading,
}: BankTransferFormProps) {
if (!bankDetails) {
return <LoadingSpinner />;
}
return (
<div className="space-y-4">
{/* Bank Details - FROM BACKEND */}
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium mb-2">Bank Transfer Details</h4>
<div className="text-sm space-y-1">
<p><span className="font-medium">Bank:</span> {bankDetails.bank_name}</p>
<p><span className="font-medium">Account Title:</span> {bankDetails.account_title}</p>
<p><span className="font-medium">Account #:</span> {bankDetails.account_number}</p>
<p><span className="font-medium">IBAN:</span> {bankDetails.iban}</p>
<p><span className="font-medium">Reference:</span> {invoiceNumber}</p>
<p><span className="font-medium">Amount:</span> {amount}</p>
</div>
</div>
{/* Form fields */}
{/* ... */}
</div>
);
}
```
---
### 2.8 Remove PendingPaymentBanner.tsx
**File:** `frontend/src/components/billing/PendingPaymentBanner.tsx`
**ACTION:** KEPT - Still useful for showing on other pages (dashboard, etc.)
The full-page `PendingPaymentView` replaces this for the /account/plans page when dealing with new users.
For existing users with missed payments, the banner can still appear on other pages.
---
### 2.9 Clean Up billing.api.ts ✅
**File:** `frontend/src/services/billing.api.ts`
**REMOVE:**
- `detectCountry()` function if exists - ✅ Not found (never existed)
- Any unused payment config functions
**KEEP:**
- All gateway functions (Stripe, PayPal)
- Invoice/Payment CRUD
- `subscribeToPlan()` and `purchaseCredits()` helpers
---
### 2.10 Clean Up authStore.ts ✅
**File:** `frontend/src/store/authStore.ts`
**UPDATE register() to NOT expect checkout_url:**
```tsx
register: async (registerData) => {
const response = await fetch(`${API_BASE_URL}/v1/auth/register/`, {
method: 'POST',
body: JSON.stringify({
email: registerData.email,
password: registerData.password,
username: registerData.username,
first_name: registerData.first_name,
last_name: registerData.last_name,
account_name: registerData.account_name,
plan_slug: registerData.plan_slug,
billing_country: registerData.billing_country,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
}
// Set auth state
set({
user: data.user,
token: data.tokens.access,
refreshToken: data.tokens.refresh,
isAuthenticated: true,
});
return data.user; // NO checkout_url
}
```
---
## Phase 3: Remove Dead Code & Fields ✅
### 3.1 Backend - Remove Unused Fields
**File:** `backend/igny8_core/auth/models.py`
Consider removing if truly unused (verify first):
- Any temporary migration fields
**Status:** Deferred - no critical dead code identified
### 3.2 Backend - Clean Up Unused Code in RegisterSerializer ✅
**File:** `backend/igny8_core/auth/urls.py`
**REMOVE:**
- Stripe checkout session creation code - REMOVED
- PayPal order creation code - REMOVED
- All `checkout_url` generation logic - REMOVED
### 3.3 Frontend - Remove Files ✅
**DELETE these files:**
- `frontend/src/pages/AuthPages/SignUpPK.tsx` - ✅ DELETED
- Any country detection utility files - None found
**Status:** SignUpPK.tsx deleted successfully
### 3.4 Frontend - Remove Unused Routes ✅
**File:** Routes configuration
**REMOVE:**
```tsx
<Route path="/signup/pk" ... />
```
**Done:** Route now redirects to main SignUp component
---
## Phase 4: Database Migrations ✅
### 4.1 Create Migration for WebhookEvent Model ✅
```bash
python manage.py makemigrations billing --name add_webhook_event_model
python manage.py migrate
```
**Done:** Migration 0029_add_webhook_event_and_manual_reference_constraint applied
### 4.2 Create Migration for Manual Reference Uniqueness ✅
```bash
python manage.py makemigrations billing --name make_manual_reference_unique
python manage.py migrate
```
**Done:** Included in migration 0029
---
## Phase 5: Testing Checklist (Manual Testing Required)
### 5.1 Signup Flow Tests
- [ ] Signup with free plan → redirects to dashboard/sites
- [ ] Signup with paid plan → redirects to /account/plans
- [ ] Signup page shows country dropdown with auto-detection
- [ ] Country dropdown shows all countries from backend
- [ ] No payment method selection on signup page
### 5.2 PendingPaymentView Tests (New Users)
- [ ] Shows for new user with `pending_payment` status
- [ ] Shows correct invoice details and plan name
- [ ] Shows Stripe + PayPal for global users
- [ ] Shows Stripe + Bank Transfer for PK users
- [ ] Stripe payment redirects and returns correctly
- [ ] PayPal payment redirects and returns correctly
- [ ] Bank transfer form submits correctly
- [ ] Bank details loaded from backend (not hardcoded)
### 5.3 ActiveSubscriptionView Tests (Existing Users)
- [ ] Shows for users who have paid before
- [ ] Shows pending payment banner if invoice overdue
- [ ] Upgrade button disabled when payment pending
- [ ] Cancel button only shows for active subscriptions
- [ ] Manage Billing only shows for Stripe users
- [ ] Credit purchase works via modal
- [ ] Invoice payment works via modal
### 5.4 Webhook Tests
- [ ] Stripe webhook processes only once (idempotency)
- [ ] PayPal webhook validates signature
- [ ] PayPal capture only processes once (idempotency)
- [ ] PayPal amount validation catches mismatches
- [ ] Webhook events stored in WebhookEvent model
### 5.5 Security Tests
- [ ] Invalid PayPal webhook signature returns 400
- [ ] Duplicate Stripe webhook doesn't create duplicate payment
- [ ] Duplicate PayPal capture returns already_captured response
- [ ] Amount manipulation in PayPal returns error
---
## Implementation Order
### Day 1: Backend Security Fixes
1. Enable PayPal webhook signature verification
2. Add Stripe webhook idempotency
3. Add PayPal capture idempotency
4. Add PayPal amount validation
5. Fix refund module imports
6. Add WebhookEvent model
7. Run migrations
### Day 2: Backend Simplification
1. Simplify RegisterSerializer (remove checkout creation)
2. Simplify RegisterView (remove checkout_url response)
3. Add CountryListView endpoint
4. Add manual_reference uniqueness migration
5. Test registration flow
### Day 3: Frontend Signup Simplification
1. Simplify SignUpFormUnified.tsx
2. Remove /signup/pk route
3. Update authStore register function
4. Test signup flow for all plans
### Day 4: Frontend Plans Page Refactor
1. Create PendingPaymentView component
2. Create ActiveSubscriptionView component
3. Refactor PlansAndBillingPage to use new views
4. Update BankTransferForm to use backend details
5. Test new user payment flow
### Day 5: Cleanup & Testing
1. Remove PendingPaymentBanner (or repurpose)
2. Clean up billing.api.ts
3. Remove dead code
4. Full integration testing
5. Update documentation
---
## Files Changed Summary
### Backend Files to Modify
| File | Changes |
|------|---------|
| `views/paypal_views.py` | Enable signature verification, add idempotency, add amount validation |
| `views/stripe_views.py` | Add idempotency check |
| `views/refund_views.py` | Fix imports |
| `models.py` | Add WebhookEvent model |
| `auth/serializers.py` | Remove checkout creation |
| `auth/views.py` | Remove checkout_url, add CountryListView |
| `auth/urls.py` | Add countries/ endpoint |
### Backend Migrations
| Migration | Purpose |
|-----------|---------|
| `add_webhook_event_model` | Audit trail for webhooks |
| `make_manual_reference_unique` | Prevent duplicate references |
### Frontend Files to Modify
| File | Changes |
|------|---------|
| `SignUpFormUnified.tsx` | Remove payment selection, add country dropdown |
| `PlansAndBillingPage.tsx` | Refactor to use new view components |
| `authStore.ts` | Remove checkout_url handling |
| `billing.api.ts` | Clean up unused functions |
### Frontend Files to Create
| File | Purpose |
|------|---------|
| `views/PendingPaymentView.tsx` | Full-page payment for new users |
| `views/ActiveSubscriptionView.tsx` | Dashboard for existing users |
| `components/BankTransferForm.tsx` | Reusable bank transfer form |
### Frontend Files to Delete
| File | Reason |
|------|--------|
| Route for `/signup/pk` | Consolidated into single signup |
| `PendingPaymentBanner.tsx` | Replaced by PendingPaymentView (optional - may keep for other pages) |
---
## Verification Commands
### Backend Verification
```bash
# Test Stripe config
curl http://localhost:8000/v1/billing/stripe/config/ -H "Authorization: Bearer $TOKEN"
# Test PayPal config
curl http://localhost:8000/v1/billing/paypal/config/ -H "Authorization: Bearer $TOKEN"
# Test country list
curl http://localhost:8000/v1/auth/countries/
# Test registration (no checkout_url in response)
curl -X POST http://localhost:8000/v1/auth/register/ \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"test123","plan_slug":"starter","billing_country":"US"}'
```
### Frontend Verification
```bash
# Build check
npm run build
# Type check
npm run typecheck
# Lint
npm run lint
```
---
## Success Criteria
1. **Signup flow simplified** - No payment redirect from signup
2. **Single payment interface** - All payments through /account/plans
3. **Security issues fixed** - Webhook verification, idempotency
---
# 🚨 CRITICAL ISSUES AFTER REFACTOR
## 🔴 **NEW CRITICAL BUGS INTRODUCED IN RECENT REFACTORS**
### Issue #1: Payment Gateway Country Filtering Broken
| # | Severity | Issue | Location | Line |
|---|----------|-------|----------|------|
| 1 | 🔴 CRITICAL | `getAvailablePaymentGateways()` called **without country code** - returns PayPal for ALL users including PK | PlansAndBillingPage.tsx | ~251 |
| 2 | 🔴 CRITICAL | `getAvailablePaymentGateways()` called **without country code** in PaymentGatewaySelector | PaymentGatewaySelector.tsx | - |
| 3 | 🔴 CRITICAL | `availableGateways` initialized with `manual: true` (Bank Transfer shows for ALL users by default) | PlansAndBillingPage.tsx | - |
| 4 | 🔴 CRITICAL | PayPal button visible in Buy Credits section for PK users | PlansAndBillingPage.tsx | - |
| 5 | 🔴 CRITICAL | PayPal button visible in Upgrade Modal for PK users | PlansAndBillingPage.tsx | - |
| 6 | 🔴 CRITICAL | Bank Transfer button visible in Upgrade Modal for NON-PK users | PlansAndBillingPage.tsx | - |
**Root Cause:**
```tsx
// CURRENT (BROKEN) - Line ~251:
const gateways = await getAvailablePaymentGateways();
// SHOULD BE:
const billingCountry = user?.account?.billing_country || 'US';
const gateways = await getAvailablePaymentGateways(billingCountry);
```
**Impact:**
- PK users can see and attempt PayPal payments (NOT ALLOWED)
- Non-PK users see Bank Transfer option (WRONG)
- Payment method selection doesn't respect country restrictions
---
### Issue #2: Stripe Return Flow Broken After Refactor
**Status:** 🔴 **BROKEN** - Payments not activating accounts
**Evidence:**
- User completes payment on Stripe
- Stripe redirects to `/account/plans?success=true`
- Invoice remains `status='pending'`
- Account remains `status='pending_payment'`
- Notification shows "payment successful" but account not activated
**Root Cause:**
1. Frontend redirect URL changed from `/plans` to `/account/plans`
2. No backend endpoint to handle return URL verification
3. Relies entirely on webhook which may be delayed
4. Frontend has no way to force-check payment status
**Missing Flow:**
```
User Returns → Check URL params → ❌ NO BACKEND CALL
→ ❌ No status verification
→ Shows old cached status
```
**Should Be:**
```
User Returns → Extract session_id from URL
→ Call /v1/billing/verify-payment-status/?session_id=xxx
→ Backend checks with Stripe
→ Return updated account/subscription status
→ Refresh UI
```
---
### Issue #3: PayPal Return Flow Broken After Refactor
**Status:** 🔴 **BROKEN** - "Payment not captured" error
**Evidence:**
- User approves payment on PayPal
- PayPal redirects to `/account/plans?paypal=success&token=xxx`
- Frontend shows "Payment not captured" error
- Order remains uncaptured
**Root Cause:**
1. Frontend expects to call `/v1/billing/paypal/capture-order/` on return
2. But `order_id` is not in URL params (PayPal uses `token` param)
3. Capture endpoint requires `order_id` not `token`
4. Mismatch between PayPal redirect params and capture endpoint
**Current Broken Flow:**
```
PayPal Redirect → ?paypal=success&token=EC-xxx
→ Frontend tries to extract order_id
→ ❌ No order_id in URL
→ ❌ Capture fails
```
**Should Be:**
```
PayPal Redirect → ?paypal=success&token=EC-xxx
→ Frontend stores order_id before redirect
→ On return, retrieve order_id from storage
→ Call capture with correct order_id
```
---
### Issue #4: Invoice Currency Mismatch
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| 7 | 🟡 HIGH | Buy Credits always shows `$` (USD) even for PK users using Bank Transfer (should show PKR) | PlansAndBillingPage.tsx |
| 8 | 🟡 HIGH | Quick Upgrade cards always show `$` (USD) for all users | PlansAndBillingPage.tsx |
| 9 | 🟡 HIGH | Upgrade Modal shows `$` for all plans even when Bank Transfer (PKR) selected | PlansAndBillingPage.tsx |
| 10 | 🟢 MEDIUM | Invoice created with currency depends on `account.payment_method` which may not be set at signup | invoice_service.py |
**Root Cause:**
- Invoice currency determined at creation time based on `account.payment_method`
- At signup, `account.payment_method` may not be set
- Frontend hardcodes `$` symbol
- No dynamic currency switching based on selected gateway
---
### Issue #5: "Manage Billing" Logic Error
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| 11 | 🟡 HIGH | "Manage Billing" shows for users with Stripe available, not users who **paid via Stripe** | PlansAndBillingPage.tsx |
**Root Cause:**
```tsx
// CURRENT (WRONG):
const canManageBilling = availableGateways.stripe && hasActivePlan;
// SHOULD BE:
const canManageBilling = user?.account?.payment_method === 'stripe'
&& user?.account?.stripe_customer_id
&& hasActivePlan;
```
**Impact:**
- PK users who paid via Bank Transfer see "Manage Billing" button
- Clicking it tries to redirect to Stripe portal (which doesn't exist for them)
---
### Issue #6: Plan Status Display Logic Error
| # | Audit Issue | Claimed Status | Actual Status |
|---|-------------|----------------|---------------|
| 1 | Plan shows "Active" even with unpaid invoice | ✅ Claimed fixed | ❌ Still using `hasActivePlan` based on plan existence only |
| 2 | "Manage Billing" shown to non-Stripe users | ✅ Claimed fixed | ❌ Shows for anyone with `availableGateways.stripe` |
| 3 | Cancel Subscription available when account pending | ✅ Claimed fixed | ❌ Logic not properly implemented |
| 4 | Payment gateway selection not synced with account | ✅ Claimed fixed | ❌ Gateway determined without country |
**Root Cause:**
```tsx
// CURRENT (WRONG):
const hasActivePlan = !!user?.account?.plan && user?.account?.plan?.slug !== 'free';
// SHOULD CHECK:
const hasActivePlan = user?.account?.status === 'active'
&& user?.account?.plan?.slug !== 'free'
&& !hasPendingInvoice;
```
---
### Issue #7: Backend Payment Method Endpoint Returns Wrong Data
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| 12 | 🟢 MEDIUM | Backend `list_payment_methods` endpoint returns ALL enabled methods ignoring country_code | billing_views.py |
**Evidence:**
```python
# Current endpoint ignores country filtering
def list_payment_methods(request):
methods = PaymentMethodConfig.objects.filter(is_enabled=True)
# ❌ No country-based filtering
return Response(methods)
```
**Should Be:**
```python
def list_payment_methods(request):
country = request.query_params.get('country_code', 'US')
methods = PaymentMethodConfig.objects.filter(
is_enabled=True,
country_code=country
)
return Response(methods)
```
---
## 🔴 **CRITICAL: Payment Return Flows NOT WORKING**
### Stripe Return Flow - BROKEN ❌
**Current State:**
```
User pays on Stripe
Stripe redirects to: /account/plans?success=true&session_id=cs_xxx
Frontend PlansAndBillingPage loads
❌ NO API CALL to verify payment
Shows cached user data (still pending_payment)
User sees "Complete your payment" (even though they just paid)
Webhook eventually fires (5-30 seconds later)
Page doesn't auto-refresh
User must manually refresh to see activation
```
**What's Missing:**
1. No `/v1/billing/verify-stripe-return/` endpoint
2. Frontend doesn't extract `session_id` from URL
3. No status polling/refresh mechanism
4. No loading state while verification happens
---
### PayPal Return Flow - BROKEN ❌
**Current State:**
```
User approves on PayPal
PayPal redirects to: /account/plans?paypal=success&token=EC-xxx&PayerID=yyy
Frontend tries to capture payment
❌ Can't find order_id (only has token)
Capture API call fails
Shows error: "Payment not captured"
User stuck with pending payment
```
**What's Missing:**
1. `order_id` not persisted before redirect
2. No mapping from `token``order_id`
3. Capture endpoint can't proceed without `order_id`
---
### Bank Transfer Flow - Working ✅
**Current State:** (This one works correctly)
```
User submits bank transfer proof
POST /v1/billing/payments/manual/
Creates Payment record (status=pending)
Admin approves payment
Account activated
✅ Works as expected
```
---
## 📊 COMPLETE END-TO-END PAYMENT WORKFLOW DIAGRAMS
### 🔵 STRIPE FLOW - US/GLOBAL USERS (Subscription)
```
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 1: SIGNUP │
└────────────────────────────────────────────────────────────────────┘
User visits /signup
Fills form: email, password, name, country=US, plan=Starter ($29/mo)
Clicks "Create Account"
POST /v1/auth/register/
└─> Body: {
email, password, username,
billing_country: 'US',
plan_slug: 'starter'
}
BACKEND ACTIONS:
1. Create User
2. Create Account (status='pending_payment', plan=Starter, credits=0)
3. Create Subscription (status='pending_payment')
4. Create Invoice (status='pending', currency='USD', amount=29.00)
5. Return: { user, tokens, ... } // ❌ NO checkout_url
Frontend redirects to → /account/plans
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 2: PAYMENT PAGE │
└────────────────────────────────────────────────────────────────────┘
/account/plans loads
Checks: accountStatus='pending_payment' && hasEverPaid=false
Shows PendingPaymentView
Displays Invoice #INV-260100001, Amount: $29.00
Payment methods: [Stripe ✅, PayPal ✅] (country=US)
User selects Stripe
Clicks "Pay $29.00"
POST /v1/billing/stripe/subscribe/
└─> Body: {
plan_slug: 'starter',
payment_method: 'stripe',
return_url: 'http://localhost:5173/account/plans?success=true',
cancel_url: 'http://localhost:5173/account/plans?canceled=true'
}
BACKEND ACTIONS (stripe_views.py - StripeSubscribeView):
1. Get plan, account
2. Create Stripe Checkout Session
- mode: 'subscription'
- line_items: [{ price: plan.stripe_price_id, quantity: 1 }]
- customer_email: user.email
- metadata: { account_id, plan_id, type: 'subscription' }
- success_url: return_url
- cancel_url: cancel_url
3. Return { redirect_url: session.url }
Frontend redirects to → session.url (Stripe hosted page)
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 3: STRIPE PAYMENT │
└────────────────────────────────────────────────────────────────────┘
User on Stripe page
Enters card details
Clicks "Pay"
Stripe processes payment
Payment succeeds
Stripe redirects to → /account/plans?success=true&session_id=cs_xxx
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 4: RETURN TO APP (❌ BROKEN) │
└────────────────────────────────────────────────────────────────────┘
Browser lands on /account/plans?success=true&session_id=cs_xxx
PlansAndBillingPage.tsx loads
useEffect runs → loadBillingData()
❌ BUG: No code to check URL params for session_id
❌ BUG: No API call to verify payment status
❌ BUG: Just loads user data from cache/API (still shows pending)
User sees: "Complete Your Subscription" (same pending view)
User confused: "I just paid!"
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 5: WEBHOOK (5-30 SEC LATER) │
└────────────────────────────────────────────────────────────────────┘
Stripe sends webhook → POST /v1/billing/stripe/webhook/
└─> Event: checkout.session.completed
BACKEND ACTIONS (stripe_views.py - _handle_checkout_completed):
✅ 1. Idempotency check - session_id in Payment.metadata
✅ 2. Get account from metadata
✅ 3. Retrieve Stripe subscription details
✅ 4. Create/Update Subscription record:
- status = 'active'
- stripe_subscription_id = 'sub_xxx'
- current_period_start, current_period_end
✅ 5. Get or create Invoice (prevents duplicates)
✅ 6. Mark Invoice as PAID:
- status = 'paid'
- paid_at = now
✅ 7. Create Payment record:
- status = 'succeeded'
- approved_at = now
- stripe_payment_intent_id
- metadata: { checkout_session_id, subscription_id }
✅ 8. Create/Update AccountPaymentMethod:
- type = 'stripe'
- is_verified = True
- is_default = True
✅ 9. Add credits:
- CreditService.add_credits(account, plan.included_credits)
✅ 10. Update Account:
- status = 'active'
- plan = plan
❌ 11. NO email sent
❌ 12. NO real-time notification to frontend
Returns 200 to Stripe
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 6: USER EXPERIENCE │
└────────────────────────────────────────────────────────────────────┘
User still on /account/plans
Page shows pending payment (cached data)
❌ Page doesn't auto-refresh
❌ No websocket notification
User must MANUALLY refresh page
On refresh: loadBillingData() fetches new status
NOW shows: Active plan, credits added
✅ Finally works after manual refresh
┌────────────────────────────────────────────────────────────────────┐
│ CURRENT BUGS SUMMARY │
└────────────────────────────────────────────────────────────────────┘
🔴 CRITICAL BUGS:
1. No payment verification on return URL
2. User sees pending state after successful payment
3. Requires manual refresh to see activation
4. No loading/polling state
5. Poor UX - user thinks payment failed
✅ WORKING CORRECTLY:
1. Webhook processing
2. Account activation
3. Credit addition
4. Subscription creation
5. Invoice marking as paid
```
---
### 🟡 PAYPAL FLOW - US/GLOBAL USERS (Subscription)
```
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 1-2: SAME AS STRIPE │
└────────────────────────────────────────────────────────────────────┘
(User signup, account creation, redirect to /account/plans)
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 3: PAYPAL ORDER CREATION │
└────────────────────────────────────────────────────────────────────┘
User on /account/plans (PendingPaymentView)
Selects PayPal
Clicks "Pay with PayPal"
POST /v1/billing/paypal/subscribe/
└─> Body: {
plan_slug: 'starter',
payment_method: 'paypal',
return_url: 'http://localhost:5173/account/plans?paypal=success',
cancel_url: 'http://localhost:5173/account/plans?paypal=cancel'
}
BACKEND ACTIONS (paypal_views.py - PayPalSubscribeView):
1. Get plan, account
2. Create PayPal Order:
- intent: 'CAPTURE'
- purchase_units: [{
amount: { currency_code: 'USD', value: '29.00' },
custom_id: account.id,
description: plan.name
}]
- application_context: {
return_url: return_url,
cancel_url: cancel_url
}
3. Extract order_id from response
4. Extract approve link from response.links
5. ❌ BUG: order_id NOT stored anywhere for later retrieval
6. Return { redirect_url: approve_link }
Frontend:
❌ BUG: Doesn't store order_id before redirect
Redirects to → approve_link (PayPal page)
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 4: PAYPAL APPROVAL │
└────────────────────────────────────────────────────────────────────┘
User on PayPal page
Logs in to PayPal
Clicks "Approve"
PayPal redirects to → /account/plans?paypal=success&token=EC-xxx&PayerID=yyy
❌ BUG: URL has 'token', not 'order_id'
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 5: CAPTURE ATTEMPT (❌ BROKEN) │
└────────────────────────────────────────────────────────────────────┘
Browser lands on /account/plans?paypal=success&token=EC-xxx
PlansAndBillingPage.tsx detects ?paypal=success
Tries to extract order_id from URL params
❌ BUG: URL has 'token=EC-xxx', not 'order_id'
❌ BUG: No code to convert token → order_id
❌ BUG: No stored order_id from Phase 3
Calls POST /v1/billing/paypal/capture-order/
└─> Body: { order_id: undefined } // ❌ MISSING
Backend returns 400: "order_id required"
Frontend shows: "Payment not captured"
User stuck with pending payment
┌────────────────────────────────────────────────────────────────────┐
│ WHAT SHOULD HAPPEN (IF WORKING) │
└────────────────────────────────────────────────────────────────────┘
Frontend should:
1. Store order_id in localStorage before redirect
2. On return, retrieve order_id from localStorage
3. Call capture with correct order_id
OR:
Backend should:
1. Accept token parameter
2. Look up order_id from token via PayPal API
3. Then proceed with capture
CAPTURE FLOW (paypal_views.py - PayPalCaptureOrderView):
✅ 1. Idempotency check - order_id already captured
✅ 2. Call PayPal API to capture order
✅ 3. Extract payment type from metadata
✅ 4. If subscription:
- _process_subscription_payment()
- Create Subscription (status='active')
- Create Invoice
- Mark Invoice paid
- Create Payment record
- Create AccountPaymentMethod (type='paypal')
- Add credits
- Activate account
✅ 5. Return success
But currently this never executes due to missing order_id.
┌────────────────────────────────────────────────────────────────────┐
│ CURRENT BUGS SUMMARY │
└────────────────────────────────────────────────────────────────────┘
🔴 CRITICAL BUGS:
1. order_id not persisted before redirect
2. Return URL has 'token', not 'order_id'
3. No token → order_id mapping
4. Capture fails completely
5. Payment left uncaptured on PayPal
6. User sees error and can't complete signup
❌ COMPLETELY BROKEN - PayPal payments don't work
```
---
### 🟢 BANK TRANSFER FLOW - PAKISTAN USERS
```
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 1-2: SAME AS STRIPE │
└────────────────────────────────────────────────────────────────────┘
User signup with country=PK, plan=Starter
Account created (status='pending_payment')
Invoice created (currency='PKR', amount=8,499.00)
Redirect to /account/plans
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 3: BANK TRANSFER FORM │
└────────────────────────────────────────────────────────────────────┘
/account/plans loads
Shows PendingPaymentView
Payment methods: [Stripe ✅, Bank Transfer ✅] (country=PK)
❌ BUG: May also show PayPal if country not passed correctly
User selects Bank Transfer
Loads bank details via GET /v1/billing/payment-configs/payment-methods/?country_code=PK&payment_method=bank_transfer
Backend returns:
{
bank_name: "HBL",
account_title: "IGNY8 Platform",
account_number: "12345678",
iban: "PK36HABB0012345678901234",
swift_code: "HABBPKKA",
instructions: "..."
}
Displays bank details to user
User transfers PKR 8,499 to bank account
User fills form:
- Transaction Reference: TRX123456
- Upload proof (receipt image)
- Notes: "Paid from account ending 9876"
Clicks "Submit Payment Proof"
POST /v1/billing/payments/manual/
└─> Body: {
invoice_id: <invoice_id>,
payment_method: 'bank_transfer',
amount: 8499.00,
manual_reference: 'TRX123456',
manual_notes: '...',
proof_url: 'https://...'
}
BACKEND ACTIONS (billing_views.py):
✅ 1. Get invoice
✅ 2. Create Payment record:
- status = 'pending' (awaiting admin approval)
- payment_method = 'bank_transfer'
- manual_reference = 'TRX123456'
- manual_proof_url = proof
- approved_at = None
- approved_by = None
✅ 3. Return success
❌ 4. NO email to admin
❌ 5. NO email to user
Frontend shows: "Payment submitted for approval"
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 4: ADMIN APPROVAL │
└────────────────────────────────────────────────────────────────────┘
Admin logs into Django Admin
Navigates to Payments
Sees Payment (status='pending', reference='TRX123456')
Views proof image
Verifies transaction in bank statement
Changes status to 'succeeded'
Sets approved_by = admin_user
Clicks "Save"
BACKEND ACTIONS (admin.py - save_model or signal):
✅ 1. Mark Invoice as paid
✅ 2. Update Subscription status = 'active'
✅ 3. Add credits to account
✅ 4. Update Account:
- status = 'active'
- payment_method = 'bank_transfer'
✅ 5. Create AccountPaymentMethod:
- type = 'bank_transfer'
- is_verified = True
- is_default = True
❌ 6. NO email to user about approval
┌────────────────────────────────────────────────────────────────────┐
│ PHASE 5: USER SEES ACTIVATION │
└────────────────────────────────────────────────────────────────────┘
User refreshes /account/plans
loadBillingData() fetches updated status
Shows: Active plan, credits available
✅ Works correctly after admin approval
┌────────────────────────────────────────────────────────────────────┐
│ CURRENT BUGS SUMMARY │
└────────────────────────────────────────────────────────────────────┘
🟡 MINOR BUGS:
1. No email notification to admin
2. No email to user on approval
3. Currency symbol shows '$' in UI (should show 'Rs')
4. ❌ PayPal may show for PK users if country filtering broken
✅ WORKING CORRECTLY:
1. Bank details from backend
2. Manual payment submission
3. Admin approval flow
4. Account activation after approval
```
---
### 💳 CREDIT PURCHASE FLOWS
#### Stripe Credit Purchase (US/Global)
```
User on /account/plans (Active account)
Clicks "Buy Credits"
Selects package: 1000 credits for $50
Clicks "Buy with Stripe"
POST /v1/billing/stripe/purchase-credits/
└─> Body: {
credit_package_id: <package_id>,
payment_method: 'stripe',
return_url: '...',
cancel_url: '...'
}
BACKEND:
1. Create Stripe Checkout Session (mode='payment')
2. metadata: { credit_package_id, credit_amount, type: 'credits' }
3. Return redirect_url
User pays on Stripe → redirects back
❌ SAME BUG: No payment verification on return
Webhook fires → _add_purchased_credits()
✅ Creates Invoice
✅ Marks paid
✅ Creates Payment
✅ Adds credits
User must manual refresh to see new credits
```
#### PayPal Credit Purchase (US/Global)
```
User clicks "Buy with PayPal"
POST /v1/billing/paypal/purchase-credits/
Creates PayPal Order
❌ SAME BUGS: order_id not stored, token not mapped
User approves on PayPal
Return URL has token
❌ Capture fails - no order_id
Payment not completed
```
#### Bank Transfer Credit Purchase (PK)
```
User selects amount → bank transfer form
Submits proof → creates Payment (pending)
Admin approves → adds credits
✅ Works correctly
```
---
## 🔄 RECURRING PAYMENT FLOWS
### Stripe Subscription Renewal
```
30 days after activation
Stripe auto-charges customer
Stripe sends webhook: invoice.paid
BACKEND (_handle_invoice_paid):
✅ 1. Skip if billing_reason = 'subscription_create'
✅ 2. Get Subscription from stripe_subscription_id
✅ 3. Add renewal credits:
CreditService.add_credits(account, plan.included_credits)
✅ 4. Update subscription period dates
❌ 5. NO email sent
✅ WORKS CORRECTLY
```
### Stripe Payment Failure
```
Renewal charge fails (card declined)
Stripe sends webhook: invoice.payment_failed
BACKEND (_handle_invoice_payment_failed):
✅ 1. Update Subscription status = 'past_due'
✅ 2. Log failure
❌ 3. TODO: Send email (not implemented)
⚠️ PARTIAL - Updates status but no notification
```
### PayPal Recurring (If using PayPal Subscriptions)
```
PayPal sends: BILLING.SUBSCRIPTION.ACTIVATED
BACKEND (_handle_subscription_activated):
✅ Creates/updates Subscription
✅ Adds credits
✅ Activates account
PayPal sends: BILLING.SUBSCRIPTION.PAYMENT.FAILED
BACKEND (_handle_subscription_payment_failed):
✅ Updates status = 'past_due'
❌ TODO: Send email
⚠️ PARTIAL - Works but no notifications
```
---
## 🎯 ROOT CAUSES OF ALL ISSUES
### 1. Country Filtering Not Applied
**One line fix in PlansAndBillingPage.tsx:**
```tsx
// Line ~251 - ADD country parameter:
const billingCountry = user?.account?.billing_country || 'US';
const gateways = await getAvailablePaymentGateways(billingCountry);
```
### 2. No Payment Return Verification
**Need new backend endpoints:**
```python
# stripe_views.py
class VerifyStripeReturnView(APIView):
def get(self, request):
session_id = request.query_params.get('session_id')
# Retrieve session from Stripe
# Check payment status
# Return account/subscription status
# paypal_views.py
class VerifyPayPalReturnView(APIView):
def get(self, request):
token = request.query_params.get('token')
# Map token to order_id
# Check order status with PayPal
# Return account status
```
**Frontend needs to call these on return:**
```tsx
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('success') && params.get('session_id')) {
verifyStripePayment(params.get('session_id'));
}
if (params.get('paypal') === 'success' && params.get('token')) {
verifyPayPalPayment(params.get('token'));
}
}, [location.search]);
```
### 3. PayPal Order ID Not Persisted
**Fix in frontend before redirect:**
```tsx
// Before redirecting to PayPal:
const { order_id, redirect_url } = await createPayPalOrder();
localStorage.setItem('paypal_order_id', order_id);
window.location.href = redirect_url;
// On return:
const order_id = localStorage.getItem('paypal_order_id');
await capturePayPalOrder(order_id);
localStorage.removeItem('paypal_order_id');
```
### 4. Currency Display Hardcoded
**Fix dynamic currency:**
```tsx
const getCurrencySymbol = (country: string, method: string) => {
if (method === 'bank_transfer' && country === 'PK') return 'Rs';
return '$';
};
const getCurrencyCode = (country: string, method: string) => {
if (method === 'bank_transfer' && country === 'PK') return 'PKR';
return 'USD';
};
```
### 5. Status Logic Inconsistent
**Fix hasActivePlan:**
```tsx
const hasActivePlan = user?.account?.status === 'active'
&& user?.account?.plan?.slug !== 'free'
&& !hasPendingInvoice;
const canManageBilling = user?.account?.payment_method === 'stripe'
&& user?.account?.stripe_customer_id
&& hasActivePlan;
```
---
## 📋 COMPLETE ISSUE CHECKLIST
| # | Issue | Severity | Status | Fix Required |
|---|-------|----------|--------|--------------|
| 1 | Country not passed to payment gateway selector | 🔴 CRITICAL | Open | Add country param |
| 2 | Stripe return doesn't verify payment | 🔴 CRITICAL | Open | Add verification endpoint |
| 3 | PayPal order_id not persisted | 🔴 CRITICAL | Open | Store in localStorage |
| 4 | PayPal token not mapped to order_id | 🔴 CRITICAL | Open | Backend mapping or localStorage |
| 5 | Invoice remains pending after Stripe success | 🔴 CRITICAL | Open | Add return verification |
| 6 | PayPal capture fails on return | 🔴 CRITICAL | Open | Fix order_id persistence |
| 7 | Currency hardcoded to USD | 🟡 HIGH | Open | Dynamic currency by country/method |
| 8 | Manage Billing shows for wrong users | 🟡 HIGH | Open | Check actual payment_method |
| 9 | hasActivePlan logic incorrect | 🟡 HIGH | Open | Check account status properly |
| 10 | No real-time status update after payment | 🟡 HIGH | Open | Polling or websocket |
| 11 | Backend payment methods ignore country | 🟢 MEDIUM | Open | Filter by country_code |
| 12 | No email notifications | 🟢 MEDIUM | Deferred | Add email service |
| 13 | No webhook audit trail | 🟢 MEDIUM | Open | Store in WebhookEvent model |
---
## 🚀 PRIORITY FIX ORDER
### IMMEDIATE (Do First):
1. Fix country filtering in gateway selection
2. Add Stripe return verification endpoint
3. Fix PayPal order_id persistence
4. Test end-to-end payment flows
### HIGH PRIORITY (Do Next):
5. Dynamic currency display
6. Fix hasActivePlan logic
7. Fix Manage Billing button logic
8. Add webhook event storage
### MEDIUM PRIORITY (Do After):
9. Real-time status updates (polling/websocket)
10. Email notifications
11. Admin notification for manual payments
---
*End of Critical Issues Analysis*
4. **Country handling simplified** - Dropdown instead of detection API
5. **Code cleaned up** - No dead routes, no hardcoded values
6. **All payment methods work** - Stripe, PayPal, Bank Transfer
7. **State properly reflected** - Badges, buttons based on actual status
---
*Plan created based on comprehensive audit of IGNY8 payment system.*