# Payment System Audit Report
> **Audit Date:** January 7, 2026
> **Status:** Complete (Updated with Frontend UX Audit)
> **Severity Levels:** CRITICAL | HIGH | MEDIUM | LOW
---
## Executive Summary
The IGNY8 payment system documentation is **accurate and matches the implementation**. However, the deep audit revealed **45+ issues** across security, reliability, UX, and functionality areas. The most critical concerns involve:
1. **PayPal webhook signature verification disabled** (security risk)
2. **Missing idempotency in payment processing** (double-charge risk)
3. **No admin dashboard for manual payment approval** (operational gap)
4. **Plan shows "Active" even with unpaid invoice** (misleading UX)
5. **Payment options not properly restricted by state** (UX confusion)
6. **Hardcoded currency exchange rates** (financial accuracy)
7. **Refund functions reference non-existent modules** (broken feature)
---
## Documentation Verification
### All Documented Files: VERIFIED
| Category | File | Status |
|----------|------|--------|
| Frontend Entry Points | SignUpFormUnified.tsx | EXISTS |
| | PlansAndBillingPage.tsx | EXISTS |
| | PayInvoiceModal.tsx | EXISTS |
| | PendingPaymentBanner.tsx | EXISTS |
| Frontend Services | billing.api.ts | EXISTS |
| | authStore.ts | EXISTS |
| Backend Views | stripe_views.py | EXISTS |
| | paypal_views.py | EXISTS |
| Backend Services | stripe_service.py | EXISTS |
| | paypal_service.py | EXISTS |
| | payment_service.py | EXISTS |
| | invoice_service.py | EXISTS |
| Models | billing/models.py | EXISTS |
| | auth/models.py | EXISTS |
### Country-Based Payment Logic: CORRECT
- **Pakistan (PK):** Stripe + Bank Transfer (NO PayPal)
- **Global:** Stripe + PayPal
Logic correctly implemented in:
- `SignUpFormUnified.tsx:160-186`
- `PayInvoiceModal.tsx:69-97`
- `payment_service.py:260-263`
---
## PlansAndBillingPage UX Audit (NEW)
### Overview
The `/account/plans` page (`PlansAndBillingPage.tsx`) is the central hub for subscription management. This audit identifies **critical UX issues** related to state handling, invoice lifecycle, and payment option restrictions.
---
### CRITICAL UX Issue #1: Plan Shows "Active" Even With Unpaid Invoice
**Location:** `PlansAndBillingPage.tsx:459-461`
**Current Code:**
```tsx
You have {pendingCreditPayments.length} credit purchase(s) pending approval
Bank: Standard Chartered Bank Pakistan
Account Title: IGNY8 Technologies
Account #: 01-2345678-01
``` **Problem:** Bank details hardcoded in frontend. Should come from `PaymentMethodConfig` in backend. **Fix Required:** Fetch bank details from `/v1/billing/payment-configs/payment-methods/?country_code=PK&payment_method=bank_transfer` and display dynamically. --- ### Account Lifecycle State Machine (Missing) The page doesn't follow a clear state machine. Here's what it SHOULD be: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ ACCOUNT STATES │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ Payment ┌──────────┐ Payment ┌──────────┐ │ │ │ trial │ ────────────▶ │ pending_ │ ────────────▶ │ active │ │ │ │ │ Required │ payment │ Success │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ │ │ │ │ Payment │ │ │ │ │ Failed │ │ │ ▼ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ expired │ │suspended │ ◀─────────── │ past_due │ │ │ │ │ │ │ Auto │ │ │ │ └──────────┘ └──────────┘ Suspend └──────────┘ │ │ │ │ │ │ │ Admin │ │ │ │ Action │ │ │ ▼ │ │ │ ┌──────────┐ │ │ │ │cancelled │ ◀──────────────────┘ │ │ │ │ User Cancel │ │ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` **Each State Should Show:** | State | Plan Badge | Actions Available | Warnings | |-------|------------|-------------------|----------| | `trial` | "Trial (X days)" | Upgrade, Buy Credits | "Trial ends [date]" | | `pending_payment` | "Awaiting Payment" | Pay Invoice, Change Method | "Complete payment to activate" | | `active` | "Active" | Upgrade, Buy Credits, Cancel, Manage | None | | `past_due` | "Payment Overdue" | Update Payment, Pay Now | "Update payment to avoid suspension" | | `suspended` | "Suspended" | Pay to Reactivate | "Account suspended - pay to restore" | | `cancelled` | "Cancels [date]" | Resubscribe | "Access ends [date]" | --- ### Payment Options By State (Missing Restrictions) **Current:** All payment options shown regardless of account state. **Required Restrictions:** | Account State | Can Upgrade? | Can Buy Credits? | Can Change Plan? | Can Cancel? | |---------------|--------------|------------------|------------------|-------------| | `trial` | Yes | Yes | N/A | No | | `pending_payment` | No (Pay first) | No (Pay first) | No | No | | `active` | Yes | Yes | Yes | Yes | | `past_due` | No (Pay first) | No | No | Yes | | `suspended` | No | No | No | No | | `cancelled` | Yes (Resubscribe) | No | No | No | --- ### Summary of PlansAndBillingPage Fixes Needed | # | Issue | Severity | Effort | |---|-------|----------|--------| | 1 | Plan shows "Active" with unpaid invoice | CRITICAL | 30 min | | 2 | Subscription states not reflected | CRITICAL | 1 hr | | 3 | Upgrade available when payment pending | HIGH | 15 min | | 4 | Cancel available for unpaid subscriptions | HIGH | 15 min | | 5 | Manage Billing shown to non-Stripe users | HIGH | 15 min | | 6 | No pending credit purchase indicator | HIGH | 30 min | | 7 | Invoice status colors inconsistent | MEDIUM | 15 min | | 8 | No payment method shown per invoice | MEDIUM | 30 min | | 9 | Renewal date context wrong | MEDIUM | 15 min | | 10 | Gateway selection not synced | MEDIUM | 30 min | | 11 | Annual billing does nothing | LOW | 2 hrs | | 12 | Bank details hardcoded | LOW | 1 hr | --- ## Critical Issues (Immediate Action Required) ### 1. PayPal Webhook Signature Not Enforced **Location:** `paypal_views.py:498-511` **Problem:** ```python if not is_valid: logger.warning("PayPal webhook signature verification failed") # Optionally reject invalid signatures # return Response({'error': 'Invalid signature'}, status=400) # COMMENTED OUT! ``` **Risk:** Malicious actors can craft fake webhook events to: - Approve payments that never happened - Cancel legitimate subscriptions - Add credits without payment **Fix Required:** ```python if not is_valid: logger.error("PayPal webhook signature verification failed") return Response({'error': 'Invalid signature'}, status=400) # UNCOMMENT ``` --- ### 2. Stripe Webhook Not Idempotent (Double-Charge Risk) **Location:** `stripe_views.py:380-391` (_handle_checkout_completed) **Problem:** Webhook can be called multiple times for same event. No check prevents duplicate invoice/payment creation. **Scenario:** 1. Stripe sends webhook 2. Invoice and payment created 3. Stripe retries webhook (network timeout) 4. **Duplicate invoice and payment created** **Fix Required:** ```python # At start of _handle_checkout_completed: session_id = session.get('id') if Payment.objects.filter(stripe_checkout_session_id=session_id).exists(): logger.info(f"Webhook already processed for session {session_id}") return # Already processed ``` --- ### 3. PayPal Capture Order Not Idempotent **Location:** `paypal_views.py:261-365` (PayPalCaptureOrderView) **Problem:** If frontend calls `/capture-order/` twice (network timeout), payment captured twice. **Fix Required:** ```python # Check if order already captured existing = Payment.objects.filter(paypal_order_id=order_id, status='succeeded').first() if existing: return Response({'status': 'already_captured', 'payment_id': existing.id}) ``` --- ### 4. Refund Functions Call Non-Existent Modules **Location:** `refund_views.py:160-208` **Problem:** ```python from igny8_core.business.billing.utils.payment_gateways import get_stripe_client # DOESN'T EXIST from igny8_core.business.billing.utils.payment_gateways import get_paypal_client # DOESN'T EXIST ``` **Risk:** Refunds marked as processed but **never actually charged back** to customer. **Fix Required:** Create the missing modules or use existing service classes: ```python from igny8_core.business.billing.services.stripe_service import StripeService from igny8_core.business.billing.services.paypal_service import PayPalService ``` --- ### 5. Amount Validation Missing for PayPal **Location:** `paypal_views.py:569` **Problem:** ```python amount = float(capture_result.get('amount', package.price)) # Trusts PayPal amount ``` **Risk:** If PayPal returns wrong amount, system processes it as correct. **Fix Required:** ```python captured_amount = float(capture_result.get('amount', 0)) expected_amount = float(package.price) if abs(captured_amount - expected_amount) > 0.01: # Allow 1 cent tolerance logger.error(f"Amount mismatch: captured={captured_amount}, expected={expected_amount}") return Response({'error': 'Amount mismatch'}, status=400) ``` --- ## High Priority Issues ### 6. No Admin Dashboard for Pending Payments **Problem:** Admins must use Django admin to approve manual payments. **Missing Endpoint:** ``` GET /v1/admin/billing/pending-payments/ - List pending approvals POST /v1/admin/billing/payments/{id}/approve/ - Approve payment POST /v1/admin/billing/payments/{id}/reject/ - Reject payment ``` **Required Implementation:** ```python class AdminPaymentViewSet(viewsets.ModelViewSet): permission_classes = [IsAdminUser] @action(detail=False, methods=['get']) def pending(self, request): payments = Payment.objects.filter(status='pending_approval') return Response(PaymentSerializer(payments, many=True).data) @action(detail=True, methods=['post']) def approve(self, request, pk=None): payment = self.get_object() PaymentService.approve_manual_payment(payment, request.user.id, request.data.get('notes')) return Response({'status': 'approved'}) ``` --- ### 7. Invoice Number Race Condition **Location:** `invoice_service.py:52-78` **Problem:** ```python count = Invoice.objects.select_for_update().filter(...).count() invoice_number = f"{prefix}-{count + 1:04d}" while Invoice.objects.filter(invoice_number=invoice_number).exists(): # NOT LOCKED! count += 1 ``` **Fix Required:** ```python # Use database unique constraint + retry logic @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}" try: # Use get_or_create with unique constraint return invoice_number except IntegrityError: continue raise ValueError("Unable to generate unique invoice number") ``` --- ### 8. Browser Redirect Lost After Payment **Problem:** If user closes browser after Stripe payment but before redirect: - Payment succeeds (webhook processes it) - User doesn't know payment succeeded - May attempt to pay again **Fix Required:** Add payment status check endpoint: ```python # New endpoint GET /v1/billing/payment-status/{session_id}/ # Frontend should check this on /account/plans load const checkPaymentStatus = async (sessionId) => { const response = await fetch(`/v1/billing/payment-status/${sessionId}/`); if (response.data.status === 'completed') { toast.success('Your previous payment was successful!'); refreshUser(); } }; ``` --- ### 9. Subscription Renewal Gets Stuck **Location:** `subscription_renewal.py:78-131` **Problem:** Status set to `pending_renewal` with no expiry or retry mechanism. **Fix Required:** ```python # Add Celery task @app.task def check_stuck_renewals(): """Run daily to check for stuck renewals""" stuck = Subscription.objects.filter( status='pending_renewal', metadata__renewal_required_at__lt=timezone.now() - timedelta(days=7) ) for sub in stuck: # Send reminder email send_renewal_reminder(sub) # After 14 days, suspend if sub.metadata.get('renewal_required_at') < timezone.now() - timedelta(days=14): sub.status = 'past_due' sub.account.status = 'suspended' sub.save() ``` --- ### 10. Currency Exchange Rates Hardcoded **Location:** `currency.py:137-157` **Problem:** ```python CURRENCY_MULTIPLIERS = { 'PKR': 278.0, # STATIC - real rate changes daily! 'INR': 83.0, # ... } ``` **Risk:** Users charged incorrect amounts over time. **Fix Required:** ```python class ExchangeRateService: CACHE_KEY = 'exchange_rates' CACHE_TTL = 86400 # 24 hours @classmethod def get_rate(cls, currency): rates = cache.get(cls.CACHE_KEY) if not rates: rates = cls._fetch_from_api() cache.set(cls.CACHE_KEY, rates, cls.CACHE_TTL) return rates.get(currency, 1.0) @classmethod def _fetch_from_api(cls): # Use OpenExchangeRates, Fixer.io, or similar response = requests.get('https://api.exchangerate-api.com/v4/latest/USD') return response.json()['rates'] ``` --- ## Medium Priority Issues ### 11. No Promo Code/Discount Support **Missing Models:** ```python class PromoCode(models.Model): code = models.CharField(max_length=50, unique=True) discount_type = models.CharField(choices=[('percent', '%'), ('fixed', '$')]) discount_value = models.DecimalField(max_digits=10, decimal_places=2) valid_from = models.DateTimeField() valid_until = models.DateTimeField(null=True) max_uses = models.IntegerField(null=True) current_uses = models.IntegerField(default=0) applicable_plans = models.ManyToManyField('Plan', blank=True) ``` --- ### 12. No Partial Payment Support **Current:** User must pay full invoice amount. **Needed:** - Split invoice into multiple payments - Track partial payment progress - Handle remaining balance --- ### 13. No Dunning Management **Missing:** When payment fails: - Day 1: Payment failed notification - Day 3: Retry attempt + reminder - Day 7: Second retry + warning - Day 14: Account suspension warning - Day 21: Account suspended --- ### 14. No Manual Payment Reference Uniqueness **Location:** `models.py:487-490` **Fix:** ```python manual_reference = models.CharField( max_length=255, blank=True, unique=True, # ADD THIS null=True # Allow null for non-manual payments ) ``` --- ### 15. Refund Credit Deduction Race Condition **Location:** `refund_views.py:108-129` **Fix:** Use `select_for_update()`: ```python with transaction.atomic(): account = Account.objects.select_for_update().get(id=payment.account_id) if account.credit_balance >= credits_to_deduct: account.credit_balance -= credits_to_deduct account.save() ``` --- ### 16. Invoice Total Calculation Silent Failure **Location:** `models.py:439-448` **Problem:** ```python except Exception: pass # SILENT FAILURE! ``` **Fix:** ```python except Exception as e: logger.error(f"Invalid line item in invoice {self.id}: {item}, error: {e}") raise ValueError(f"Invalid invoice line item: {item}") ``` --- ### 17. No Webhook Event Storage **Missing:** All incoming webhooks should be stored for: - Audit trail - Replay on failure - Debugging **Add Model:** ```python class WebhookEvent(models.Model): event_id = models.CharField(max_length=255, unique=True) provider = models.CharField(max_length=20) # stripe, paypal event_type = models.CharField(max_length=100) payload = models.JSONField() processed = models.BooleanField(default=False) processed_at = models.DateTimeField(null=True) error_message = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) ``` --- ### 18. No Payment Audit Trail for Rejections **Missing Fields in Payment model:** ```python rejected_by = models.ForeignKey('User', null=True, related_name='rejected_payments') rejected_at = models.DateTimeField(null=True) rejection_reason = models.TextField(blank=True) ``` --- ## Recommendations Summary ### Immediate (This Week) | # | Issue | Effort | Impact | |---|-------|--------|--------| | 1 | Enable PayPal webhook signature verification | 5 min | CRITICAL | | 2 | Add Stripe webhook idempotency check | 30 min | CRITICAL | | 3 | Add PayPal capture idempotency check | 30 min | CRITICAL | | 4 | Fix refund module imports | 1 hr | CRITICAL | | 5 | Add PayPal amount validation | 30 min | CRITICAL | ### Short-Term (This Month) | # | Issue | Effort | Impact | |---|-------|--------|--------| | 6 | Build admin pending payments dashboard | 1 day | HIGH | | 7 | Fix invoice number race condition | 2 hrs | HIGH | | 8 | Add payment status check endpoint | 2 hrs | HIGH | | 9 | Fix stuck renewal subscriptions | 1 day | HIGH | | 10 | Implement dynamic currency rates | 1 day | HIGH | ### Medium-Term (This Quarter) | # | Issue | Effort | Impact | |---|-------|--------|--------| | 11 | Promo code system | 3 days | MEDIUM | | 12 | Partial payment support | 2 days | MEDIUM | | 13 | Dunning management | 2 days | MEDIUM | | 14 | Webhook event storage | 1 day | MEDIUM | | 15 | Payment audit trail | 1 day | MEDIUM | --- ## Architecture Assessment ### What's Good 1. **Clean separation** between frontend entry points, services, and backend 2. **Country-based logic** correctly isolates Pakistan users from PayPal 3. **Multi-gateway support** (Stripe, PayPal, Manual) well architected 4. **Service layer abstraction** (`StripeService`, `PayPalService`, `PaymentService`) 5. **Invoice and payment tracking** comprehensive 6. **Webhook handlers** exist for both gateways ### What Needs Improvement 1. **Idempotency** - Critical for payment processing 2. **Transaction safety** - Need more `@transaction.atomic()` and `select_for_update()` 3. **Observability** - No webhook event storage, limited metrics 4. **Admin tooling** - Manual payments need proper dashboard 5. **Error handling** - Too many silent failures 6. **Feature gaps** - No promo codes, partial payments, dunning --- ## Final Assessment | Area | Rating | Notes | |------|--------|-------| | Documentation Accuracy | A | Matches codebase | | Security | C | Webhook verification gaps | | Reliability | C | Idempotency issues | | Completeness | B | Core features present | | Admin Experience | D | No proper dashboard | | User Experience | B | Good flows, missing status checks | | Code Quality | B | Good structure, some silent failures | **Overall Grade: C+** The payment system is functional but has critical security and reliability gaps that must be addressed before scaling. The architecture is sound, but implementation details need hardening. --- ## Quick Wins (Can Do Today) 1. Uncomment PayPal webhook signature rejection (5 min) 2. Add `@transaction.atomic()` to all payment handlers (30 min) 3. Add duplicate check before creating payments (30 min) 4. Add unique constraint to `manual_reference` (migration) 5. Remove silent `except: pass` blocks (30 min) --- *Report generated by deep audit of IGNY8 payment system codebase.*