41 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
- Country handling simplified - Dropdown instead of detection API
- Code cleaned up - No dead routes, no hardcoded values
- All payment methods work - Stripe, PayPal, Bank Transfer
- State properly reflected - Badges, buttons based on actual status
Plan created based on comprehensive audit of IGNY8 payment system.