2366 lines
76 KiB
Markdown
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.*
|