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