76 KiB
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:
- Simplify signup flow - Remove payment gateway redirect from signup
- Unify payment experience - Single payment interface in
/account/plans - Fix all critical security issues - Webhook verification, idempotency
- Clean up dead code - Remove
/pkvariant, country detection API, unused fields - 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
# 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
# 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
# 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
# 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
# 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_urlreturncheckout_session_idreturnpaypal_order_idreturn
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):
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_urlin response
NEW response (simplified):
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)
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
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)
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
# In Invoice model
invoice_number = models.CharField(max_length=50, unique=True) # Already has unique=True
Update invoice_service.py to handle IntegrityError:
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:
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
isPakistanSignupvariant 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:
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: ✅
// DELETE THIS ROUTE
<Route path="/signup/pk" element={<SignupPage isPakistan />} />
KEEP only: ✅
<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 detectionfrontend/src/services/billing.api.ts- RemovedetectCountry()function if exists
NEW approach: Simple IP-based detection for dropdown default only: ✅
// 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: ✅
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) ✅
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
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: ✅
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()andpurchaseCredits()helpers
2.10 Clean Up authStore.ts ✅
File: frontend/src/store/authStore.ts
UPDATE register() to NOT expect checkout_url: ✅
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_urlgeneration 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: ✅
<Route path="/signup/pk" ... />
Done: Route now redirects to main SignUp component
Phase 4: Database Migrations ✅
4.1 Create Migration for WebhookEvent Model ✅
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 ✅
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_paymentstatus - 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
- Enable PayPal webhook signature verification
- Add Stripe webhook idempotency
- Add PayPal capture idempotency
- Add PayPal amount validation
- Fix refund module imports
- Add WebhookEvent model
- Run migrations
Day 2: Backend Simplification
- Simplify RegisterSerializer (remove checkout creation)
- Simplify RegisterView (remove checkout_url response)
- Add CountryListView endpoint
- Add manual_reference uniqueness migration
- Test registration flow
Day 3: Frontend Signup Simplification
- Simplify SignUpFormUnified.tsx
- Remove /signup/pk route
- Update authStore register function
- Test signup flow for all plans
Day 4: Frontend Plans Page Refactor
- Create PendingPaymentView component
- Create ActiveSubscriptionView component
- Refactor PlansAndBillingPage to use new views
- Update BankTransferForm to use backend details
- Test new user payment flow
Day 5: Cleanup & Testing
- Remove PendingPaymentBanner (or repurpose)
- Clean up billing.api.ts
- Remove dead code
- Full integration testing
- 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
# 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
# Build check
npm run build
# Type check
npm run typecheck
# Lint
npm run lint
Success Criteria
- Signup flow simplified - No payment redirect from signup
- Single payment interface - All payments through /account/plans
- 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:
// 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:
- Frontend redirect URL changed from
/plansto/account/plans - No backend endpoint to handle return URL verification
- Relies entirely on webhook which may be delayed
- 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:
- Frontend expects to call
/v1/billing/paypal/capture-order/on return - But
order_idis not in URL params (PayPal usestokenparam) - Capture endpoint requires
order_idnottoken - 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_methodmay 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:
// 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:
// 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:
# 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:
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:
- No
/v1/billing/verify-stripe-return/endpoint - Frontend doesn't extract
session_idfrom URL - No status polling/refresh mechanism
- 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:
order_idnot persisted before redirect- No mapping from
token→order_id - 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:
// 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:
# 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:
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:
// 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:
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:
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):
- Fix country filtering in gateway selection
- Add Stripe return verification endpoint
- Fix PayPal order_id persistence
- Test end-to-end payment flows
HIGH PRIORITY (Do Next):
- Dynamic currency display
- Fix hasActivePlan logic
- Fix Manage Billing button logic
- Add webhook event storage
MEDIUM PRIORITY (Do After):
- Real-time status updates (polling/websocket)
- Email notifications
- 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.