Files
igny8/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md
2026-01-07 13:02:53 +00:00

1351 lines
41 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
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.*