Version 1.6.0
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,959 +0,0 @@
|
||||
# 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
|
||||
<Badge variant="solid" tone={hasActivePlan ? 'success' : 'warning'}>
|
||||
{hasActivePlan ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
- `hasActivePlan` is `true` if user has ANY plan assigned (Line 384)
|
||||
- User who signed up for paid plan but never completed payment sees **"Active"**
|
||||
- This is misleading - their account is actually `pending_payment`
|
||||
|
||||
**Correct Logic Should Check:**
|
||||
1. `account.status === 'active'` (not just plan existence)
|
||||
2. No pending invoices
|
||||
3. Subscription status is `active` (not `pending_payment`)
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
const accountStatus = user?.account?.status;
|
||||
const subscriptionStatus = currentSubscription?.status;
|
||||
const isFullyActive = accountStatus === 'active' &&
|
||||
subscriptionStatus === 'active' &&
|
||||
!hasPendingInvoice;
|
||||
|
||||
<Badge variant="solid" tone={isFullyActive ? 'success' : accountStatus === 'pending_payment' ? 'warning' : 'error'}>
|
||||
{isFullyActive ? 'Active' : accountStatus === 'pending_payment' ? 'Pending Payment' : 'Inactive'}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CRITICAL UX Issue #2: Subscription States Not Properly Reflected
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:379-386`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
const currentSubscription = subscriptions.find((sub) => sub.status === 'active') || subscriptions[0];
|
||||
// ...
|
||||
const hasActivePlan = Boolean(effectivePlanId);
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
The page doesn't distinguish between subscription statuses:
|
||||
- `active` - Paid and working
|
||||
- `pending_payment` - Waiting for first payment
|
||||
- `past_due` - Renewal payment failed
|
||||
- `canceled` - User cancelled
|
||||
|
||||
**Missing Status Handling:**
|
||||
|
||||
| Subscription Status | What User Sees | What They SHOULD See |
|
||||
|---------------------|----------------|----------------------|
|
||||
| `pending_payment` | "Active" badge | "Payment Required" with prominent CTA |
|
||||
| `past_due` | No indication | "Payment Overdue" warning |
|
||||
| `canceled` | May still show "Active" | "Cancels on [date]" |
|
||||
| `trialing` | "Active" | "Trial (X days left)" |
|
||||
|
||||
**Fix Required:** Add comprehensive status display:
|
||||
```tsx
|
||||
const getSubscriptionDisplay = () => {
|
||||
if (!currentSubscription) return { label: 'No Plan', tone: 'error' };
|
||||
|
||||
switch (currentSubscription.status) {
|
||||
case 'active':
|
||||
return hasPendingInvoice
|
||||
? { label: 'Payment Due', tone: 'warning' }
|
||||
: { label: 'Active', tone: 'success' };
|
||||
case 'pending_payment':
|
||||
return { label: 'Awaiting Payment', tone: 'warning' };
|
||||
case 'past_due':
|
||||
return { label: 'Payment Overdue', tone: 'error' };
|
||||
case 'canceled':
|
||||
return { label: `Cancels ${formatDate(currentSubscription.cancel_at)}`, tone: 'warning' };
|
||||
case 'trialing':
|
||||
return { label: `Trial`, tone: 'info' };
|
||||
default:
|
||||
return { label: currentSubscription.status, tone: 'neutral' };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #3: Upgrade Button Available When Payment Pending
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:478-486`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
startIcon={<ArrowUpIcon className="w-4 h-4" />}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Problem:** User with pending invoice can click "Upgrade" and attempt to subscribe to another plan, creating confusion and potentially duplicate subscriptions.
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
disabled={hasPendingInvoice || accountStatus === 'pending_payment'}
|
||||
startIcon={<ArrowUpIcon className="w-4 h-4" />}
|
||||
>
|
||||
{hasPendingInvoice ? 'Pay Invoice First' : 'Upgrade'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #4: Cancel Subscription Available When Account Already Pending
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:609-616`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
{hasActivePlan && (
|
||||
<button onClick={() => setShowCancelConfirm(true)} ...>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Problem:** User with `pending_payment` status can "cancel" a subscription they never paid for. This is confusing.
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
{hasActivePlan && accountStatus === 'active' && !hasPendingInvoice && (
|
||||
<button onClick={() => setShowCancelConfirm(true)} ...>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #5: "Manage Billing" Button Shown to Non-Stripe Users
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:468-477`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
{availableGateways.stripe && hasActivePlan && (
|
||||
<Button ... onClick={handleManageSubscription}>
|
||||
Manage Billing
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
- Shows "Manage Billing" if Stripe is available, even if user pays via Bank Transfer
|
||||
- Bank Transfer users clicking this get error "No Stripe customer ID"
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
{availableGateways.stripe &&
|
||||
hasActivePlan &&
|
||||
user?.account?.stripe_customer_id &&
|
||||
selectedPaymentMethod === 'stripe' && (
|
||||
<Button ... onClick={handleManageSubscription}>
|
||||
Manage Billing
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #6: Credits Section Doesn't Show Pending Credit Purchases
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:689-713`
|
||||
|
||||
**Problem:** If user purchased credits via bank transfer and it's `pending_approval`, they don't see this anywhere clearly. They might try to purchase again.
|
||||
|
||||
**Fix Required:** Add pending credits indicator:
|
||||
```tsx
|
||||
{pendingCreditPayments.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-info-50 border border-info-200 rounded-lg">
|
||||
<p className="text-sm text-info-700">
|
||||
You have {pendingCreditPayments.length} credit purchase(s) pending approval
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #7: Invoice Status Badge Colors Inconsistent
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:817-819`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
**Problem:** Only handles `paid` and everything else is `warning`. Missing:
|
||||
- `draft` - Should be gray/neutral
|
||||
- `void` - Should be gray
|
||||
- `uncollectible` - Should be error/red
|
||||
- `pending` - Warning (correct)
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
const getInvoiceStatusTone = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'success';
|
||||
case 'pending': return 'warning';
|
||||
case 'void':
|
||||
case 'draft': return 'neutral';
|
||||
case 'uncollectible': return 'error';
|
||||
default: return 'neutral';
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #8: No Clear Indication of Payment Method per Invoice
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:809-849` (Invoice table)
|
||||
|
||||
**Problem:** Invoice table doesn't show which payment method was used/expected. User can't tell if they need to do bank transfer or card payment.
|
||||
|
||||
**Fix Required:** Add payment method column:
|
||||
```tsx
|
||||
<td className="px-6 py-3 text-center">
|
||||
{invoice.payment_method === 'bank_transfer' ? (
|
||||
<span className="flex items-center gap-1"><Building2Icon className="w-4 h-4" /> Bank</span>
|
||||
) : invoice.payment_method === 'paypal' ? (
|
||||
<span className="flex items-center gap-1"><WalletIcon className="w-4 h-4" /> PayPal</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><CreditCardIcon className="w-4 h-4" /> Card</span>
|
||||
)}
|
||||
</td>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #9: Renewal Date Shows Even When No Active Subscription
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:514-522`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{currentSubscription?.current_period_end
|
||||
? new Date(currentSubscription.current_period_end).toLocaleDateString(...)
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Next billing</div>
|
||||
```
|
||||
|
||||
**Problem:** Shows "—" and "Next billing" even when:
|
||||
- Account is `pending_payment` (never billed yet)
|
||||
- Subscription is `canceled` (won't renew)
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{currentSubscription?.status === 'canceled' ? 'Ends on' :
|
||||
currentSubscription?.status === 'pending_payment' ? 'Starts after payment' :
|
||||
'Next billing'}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #10: Payment Gateway Selection Not Synced With Account
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:655-686`
|
||||
|
||||
**Problem:** Payment method selector in "Buy Credits" section can show options the user hasn't verified. If user signed up with bank transfer, they shouldn't see PayPal as an option until they've added it.
|
||||
|
||||
**Current Logic:** Shows all `availableGateways` regardless of user's `AccountPaymentMethod` records.
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
// Only show gateways user has verified OR is willing to add
|
||||
const userCanUseGateway = (gateway: PaymentGateway) => {
|
||||
const userHasMethod = userPaymentMethods.some(m =>
|
||||
(gateway === 'stripe' && m.type === 'stripe') ||
|
||||
(gateway === 'paypal' && m.type === 'paypal') ||
|
||||
(gateway === 'manual' && ['bank_transfer', 'local_wallet'].includes(m.type))
|
||||
);
|
||||
return availableGateways[gateway] && (userHasMethod || gateway === selectedGateway);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### LOW UX Issue #11: Annual Billing Toggle Does Nothing
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:959-983`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
const displayPrice = selectedBillingCycle === 'annual' ? (annualPrice / 12).toFixed(0) : planPrice;
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
- Shows "Save 20%" badge for annual
|
||||
- Calculates display price
|
||||
- But **no annual plans exist** in database
|
||||
- Clicking "Choose Plan" subscribes to monthly regardless
|
||||
|
||||
**Fix Required:** Either:
|
||||
1. Remove annual toggle until annual plans implemented
|
||||
2. Implement annual plan variants in backend
|
||||
3. Pass `billing_cycle` to `subscribeToPlan()` and handle in backend
|
||||
|
||||
---
|
||||
|
||||
### LOW UX Issue #12: PayInvoiceModal Hardcodes Bank Details
|
||||
|
||||
**Location:** `PayInvoiceModal.tsx:437-443`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<p><span className="font-medium">Bank:</span> Standard Chartered Bank Pakistan</p>
|
||||
<p><span className="font-medium">Account Title:</span> IGNY8 Technologies</p>
|
||||
<p><span className="font-medium">Account #:</span> 01-2345678-01</p>
|
||||
```
|
||||
|
||||
**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.*
|
||||
File diff suppressed because it is too large
Load Diff
677
docs/90-REFERENCE/PAYMENT-SYSTEM.md
Normal file
677
docs/90-REFERENCE/PAYMENT-SYSTEM.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# Payment System Documentation
|
||||
|
||||
> **Version:** 1.6.0
|
||||
> **Last Updated:** January 8, 2026
|
||||
> **Status:** Production Ready
|
||||
|
||||
This document provides comprehensive documentation of the IGNY8 payment system architecture, implementation, and flows.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview](#system-overview)
|
||||
2. [Payment Entry Points](#payment-entry-points)
|
||||
3. [Backend Architecture](#backend-architecture)
|
||||
4. [Frontend Architecture](#frontend-architecture)
|
||||
5. [Payment Flows](#payment-flows)
|
||||
6. [Country-Based Payment Rules](#country-based-payment-rules)
|
||||
7. [Webhook Processing](#webhook-processing)
|
||||
8. [Models Reference](#models-reference)
|
||||
9. [Security Features](#security-features)
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
### Supported Payment Methods
|
||||
|
||||
| Method | Type | Regions | Use Cases |
|
||||
|--------|------|---------|-----------|
|
||||
| **Stripe** | Credit/Debit Card | Global | Subscriptions, Credit packages |
|
||||
| **PayPal** | PayPal account | Global (except PK) | Subscriptions, Credit packages |
|
||||
| **Bank Transfer** | Manual | Pakistan (PK) | Subscriptions, Credit packages |
|
||||
|
||||
### Payment Method Selection Logic
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Country-Based Payment Rules │
|
||||
├────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Global Users (non-PK): │
|
||||
│ ✅ Stripe (Credit/Debit Card) │
|
||||
│ ✅ PayPal │
|
||||
│ ❌ Bank Transfer (not available) │
|
||||
│ │
|
||||
│ Pakistan Users (PK): │
|
||||
│ ✅ Stripe (Credit/Debit Card) │
|
||||
│ ❌ PayPal (not available in PK) │
|
||||
│ ✅ Bank Transfer (manual) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PAYMENT SYSTEM FLOW │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Signup │───────▶ │ /account/ │ │
|
||||
│ │ (no pay) │ │ plans │ │
|
||||
│ └──────────────┘ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────┴─────────────┐ │
|
||||
│ │ │ │
|
||||
│ New User? Existing User? │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ PendingPay- │ │ Plans/Billing│ │
|
||||
│ │ mentView │ │ Dashboard │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────┼─────────┐ ┌────────┼────────┐ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ ▼ ▼ │
|
||||
│ Stripe PayPal Bank Upgrade Credits Manage │
|
||||
│ Transfer │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Payment Entry Points
|
||||
|
||||
### 1. Signup Flow
|
||||
|
||||
**File:** `frontend/src/components/auth/SignUpFormUnified.tsx`
|
||||
|
||||
**Simplified Signup (No Payment on Signup):**
|
||||
- User selects plan and provides details
|
||||
- Account created with `status='pending_payment'` for paid plans
|
||||
- User redirected to `/account/plans` to complete payment
|
||||
- No payment gateway redirect from signup page
|
||||
|
||||
```typescript
|
||||
// Signup flow creates account only, no checkout
|
||||
const handleSignup = async (data) => {
|
||||
const result = await register({
|
||||
email, password, plan_slug, billing_country
|
||||
});
|
||||
// Redirect to plans page for payment
|
||||
navigate('/account/plans');
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Plans & Billing Page
|
||||
|
||||
**File:** `frontend/src/pages/account/PlansAndBillingPage.tsx`
|
||||
|
||||
Central hub for all payment-related actions:
|
||||
|
||||
**For New Users (pending_payment):**
|
||||
- Shows `PendingPaymentView` component
|
||||
- Full-page payment interface
|
||||
- Invoice details and payment method selection
|
||||
|
||||
**For Existing Users:**
|
||||
- Current plan and subscription status
|
||||
- Credit balance and purchase
|
||||
- Invoice history and downloads
|
||||
- Subscription management
|
||||
|
||||
### 3. PendingPaymentView Component
|
||||
|
||||
**File:** `frontend/src/components/billing/PendingPaymentView.tsx`
|
||||
|
||||
Full-page payment interface for new users:
|
||||
- Displays invoice details and plan info
|
||||
- Payment method selection based on country
|
||||
- Stripe/PayPal redirect or Bank Transfer form
|
||||
- Status checking for bank transfer submissions
|
||||
|
||||
### 4. Bank Transfer Form
|
||||
|
||||
**File:** `frontend/src/components/billing/BankTransferForm.tsx`
|
||||
|
||||
Manual payment submission for Pakistan users:
|
||||
- Bank account details display
|
||||
- Transaction reference input
|
||||
- File upload for payment proof
|
||||
- Submission and status tracking
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Service Layer
|
||||
|
||||
#### StripeService
|
||||
|
||||
**File:** `backend/igny8_core/business/billing/services/stripe_service.py`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_checkout_session()` | Create subscription checkout |
|
||||
| `create_credit_checkout_session()` | Create credit package checkout |
|
||||
| `create_billing_portal_session()` | Customer billing portal |
|
||||
| `get_or_create_customer()` | Stripe customer management |
|
||||
| `construct_webhook_event()` | Verify webhook signatures |
|
||||
|
||||
#### PayPalService
|
||||
|
||||
**File:** `backend/igny8_core/business/billing/services/paypal_service.py`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_order()` | Create one-time payment order |
|
||||
| `create_subscription_order()` | Create subscription order |
|
||||
| `capture_order()` | Capture approved payment |
|
||||
| `verify_webhook_signature()` | Webhook verification |
|
||||
|
||||
#### InvoiceService
|
||||
|
||||
**File:** `backend/igny8_core/business/billing/services/invoice_service.py`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_subscription_invoice()` | Invoice for plan subscription |
|
||||
| `create_credit_package_invoice()` | Invoice for credit purchase |
|
||||
| `mark_paid()` | Mark invoice as paid |
|
||||
| `generate_pdf()` | Generate PDF invoice |
|
||||
|
||||
#### PaymentService
|
||||
|
||||
**File:** `backend/igny8_core/business/billing/services/payment_service.py`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_stripe_payment()` | Record Stripe payment |
|
||||
| `create_paypal_payment()` | Record PayPal payment |
|
||||
| `create_manual_payment()` | Record bank transfer |
|
||||
| `approve_manual_payment()` | Admin approval |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Stripe Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/billing/stripe/config/` | GET | Publishable key |
|
||||
| `/v1/billing/stripe/checkout/` | POST | Create checkout session |
|
||||
| `/v1/billing/stripe/credit-checkout/` | POST | Credit package checkout |
|
||||
| `/v1/billing/stripe/billing-portal/` | POST | Billing portal |
|
||||
| `/v1/billing/webhooks/stripe/` | POST | Webhook handler |
|
||||
|
||||
#### PayPal Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/billing/paypal/config/` | GET | Client ID |
|
||||
| `/v1/billing/paypal/create-order/` | POST | Credit package order |
|
||||
| `/v1/billing/paypal/create-subscription-order/` | POST | Subscription order |
|
||||
| `/v1/billing/paypal/capture-order/` | POST | Capture payment |
|
||||
| `/v1/billing/webhooks/paypal/` | POST | Webhook handler |
|
||||
|
||||
#### Invoice Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/billing/invoices/` | GET | List invoices |
|
||||
| `/v1/billing/invoices/{id}/` | GET | Invoice detail |
|
||||
| `/v1/billing/invoices/{id}/download_pdf/` | GET | Download PDF |
|
||||
|
||||
#### Payment Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/billing/payments/` | GET | List payments |
|
||||
| `/v1/billing/payments/manual/` | POST | Submit bank transfer |
|
||||
| `/v1/billing/admin/payments/confirm/` | POST | Admin approve/reject |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Services
|
||||
|
||||
**File:** `frontend/src/services/billing.api.ts`
|
||||
|
||||
Key functions:
|
||||
|
||||
```typescript
|
||||
// Gateway availability (country-based)
|
||||
getAvailablePaymentGateways(userCountry?: string)
|
||||
|
||||
// Subscription helpers
|
||||
subscribeToPlan(planId, gateway, options)
|
||||
purchaseCredits(packageId, gateway, options)
|
||||
|
||||
// Stripe functions
|
||||
createStripeCheckout(planId, options)
|
||||
createStripeCreditCheckout(packageId, options)
|
||||
openStripeBillingPortal(returnUrl)
|
||||
|
||||
// PayPal functions
|
||||
createPayPalSubscriptionOrder(planId, options)
|
||||
createPayPalCreditOrder(packageId, options)
|
||||
capturePayPalOrder(orderId, metadata)
|
||||
|
||||
// Manual payment
|
||||
submitManualPayment(invoiceId, data)
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `PendingPaymentView` | `/components/billing/PendingPaymentView.tsx` | New user payment |
|
||||
| `BankTransferForm` | `/components/billing/BankTransferForm.tsx` | Bank transfer submission |
|
||||
| `PendingPaymentBanner` | `/components/billing/PendingPaymentBanner.tsx` | Alert for pending payments |
|
||||
| `PaymentGatewaySelector` | `/components/billing/PaymentGatewaySelector.tsx` | Gateway selection UI |
|
||||
| `PayInvoiceModal` | `/components/billing/PayInvoiceModal.tsx` | Pay invoice modal |
|
||||
|
||||
---
|
||||
|
||||
## Payment Flows
|
||||
|
||||
### Flow 1: New User Signup with Stripe
|
||||
|
||||
```
|
||||
1. User submits signup form with plan
|
||||
2. Backend creates:
|
||||
- User account
|
||||
- Account (status='pending_payment')
|
||||
- Subscription (status='pending_payment')
|
||||
- Invoice (status='pending')
|
||||
3. User redirected to /account/plans
|
||||
4. PendingPaymentView displays
|
||||
5. User selects Stripe, clicks Pay
|
||||
6. Redirect to Stripe Checkout
|
||||
7. User completes payment
|
||||
8. Stripe webhook received:
|
||||
- Payment recorded
|
||||
- Invoice marked paid
|
||||
- Account activated
|
||||
- Credits added
|
||||
9. User redirected back to /account/plans
|
||||
10. Success message, dashboard displays
|
||||
```
|
||||
|
||||
### Flow 2: New User with PayPal (Non-PK)
|
||||
|
||||
```
|
||||
1. Same as Stripe steps 1-4
|
||||
5. User selects PayPal, clicks Pay
|
||||
6. PayPal order created
|
||||
7. Redirect to PayPal approval
|
||||
8. User approves on PayPal
|
||||
9. Redirect back with order_id
|
||||
10. Frontend calls capture-order
|
||||
11. Backend processes:
|
||||
- Payment captured
|
||||
- Payment recorded
|
||||
- Invoice marked paid
|
||||
- Account activated
|
||||
- Credits added
|
||||
12. Success displayed
|
||||
```
|
||||
|
||||
### Flow 3: Pakistan User with Bank Transfer
|
||||
|
||||
```
|
||||
1. Same as signup steps 1-4
|
||||
5. User sees Stripe + Bank Transfer options
|
||||
6. User selects Bank Transfer
|
||||
7. BankTransferForm displays:
|
||||
- Bank details (SCB Pakistan)
|
||||
- Reference input
|
||||
- Proof upload option
|
||||
8. User makes transfer, submits form
|
||||
9. Backend creates:
|
||||
- Payment (status='pending_approval')
|
||||
10. User sees "Awaiting Approval" status
|
||||
11. Admin reviews in Django Admin
|
||||
12. Admin approves:
|
||||
- Payment marked succeeded
|
||||
- Invoice marked paid
|
||||
- Account activated
|
||||
- Credits added
|
||||
13. User receives email confirmation
|
||||
```
|
||||
|
||||
### Flow 4: Existing User Buys Credits
|
||||
|
||||
```
|
||||
1. User on /account/plans clicks "Buy Credits"
|
||||
2. Credit package selection modal
|
||||
3. User selects package and gateway
|
||||
4. For Stripe/PayPal: redirect flow
|
||||
5. For Bank Transfer: form submission
|
||||
6. On success: credits added to account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Country-Based Payment Rules
|
||||
|
||||
### Implementation in Frontend
|
||||
|
||||
```typescript
|
||||
// billing.api.ts
|
||||
export async function getAvailablePaymentGateways(userCountry?: string) {
|
||||
const [stripeAvailable, paypalAvailable] = await Promise.all([
|
||||
isStripeConfigured(),
|
||||
isPayPalConfigured(),
|
||||
]);
|
||||
|
||||
const isPakistan = userCountry?.toUpperCase() === 'PK';
|
||||
|
||||
return {
|
||||
stripe: stripeAvailable,
|
||||
// PayPal: NOT available for Pakistan
|
||||
paypal: !isPakistan && paypalAvailable,
|
||||
// Bank Transfer: ONLY for Pakistan
|
||||
manual: isPakistan,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
```typescript
|
||||
// PendingPaymentView.tsx
|
||||
const isPakistan = userCountry === 'PK';
|
||||
|
||||
// Load gateways with country filter
|
||||
const gateways = await getAvailablePaymentGateways(userCountry);
|
||||
|
||||
// Show appropriate options
|
||||
const paymentOptions = [
|
||||
{ type: 'stripe', ... }, // Always shown if configured
|
||||
isPakistan ? { type: 'manual', ... } : { type: 'paypal', ... },
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Processing
|
||||
|
||||
### Stripe Webhooks
|
||||
|
||||
**Endpoint:** `POST /v1/billing/webhooks/stripe/`
|
||||
|
||||
| Event | Handler Action |
|
||||
|-------|----------------|
|
||||
| `checkout.session.completed` | Activate subscription, add credits |
|
||||
| `invoice.paid` | Add renewal credits |
|
||||
| `invoice.payment_failed` | Send notification |
|
||||
| `customer.subscription.updated` | Sync changes |
|
||||
| `customer.subscription.deleted` | Cancel subscription |
|
||||
|
||||
**Idempotency:** Checks `WebhookEvent` model before processing:
|
||||
```python
|
||||
# Check if already processed
|
||||
if WebhookEvent.objects.filter(
|
||||
provider='stripe',
|
||||
event_id=event_id,
|
||||
status='processed'
|
||||
).exists():
|
||||
return # Already handled
|
||||
```
|
||||
|
||||
### PayPal Webhooks
|
||||
|
||||
**Endpoint:** `POST /v1/billing/webhooks/paypal/`
|
||||
|
||||
| Event | Handler Action |
|
||||
|-------|----------------|
|
||||
| `CHECKOUT.ORDER.APPROVED` | Auto-capture if configured |
|
||||
| `PAYMENT.CAPTURE.COMPLETED` | Mark succeeded, add credits |
|
||||
| `PAYMENT.CAPTURE.DENIED` | Mark failed |
|
||||
| `BILLING.SUBSCRIPTION.ACTIVATED` | Activate subscription |
|
||||
|
||||
**Signature Verification:** Enabled and enforced:
|
||||
```python
|
||||
is_valid = service.verify_webhook_signature(...)
|
||||
if not is_valid:
|
||||
return Response({'error': 'Invalid signature'}, status=400)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Models Reference
|
||||
|
||||
### Invoice Model
|
||||
|
||||
```python
|
||||
class Invoice(AccountBaseModel):
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Draft'),
|
||||
('pending', 'Pending'),
|
||||
('paid', 'Paid'),
|
||||
('void', 'Void'),
|
||||
('uncollectible', 'Uncollectible'),
|
||||
]
|
||||
|
||||
invoice_number = models.CharField(max_length=50, unique=True)
|
||||
subscription = models.ForeignKey('auth.Subscription', ...)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
|
||||
subtotal = models.DecimalField(...)
|
||||
tax = models.DecimalField(...)
|
||||
total = models.DecimalField(...)
|
||||
currency = models.CharField(max_length=3, default='USD')
|
||||
due_date = models.DateField()
|
||||
line_items = models.JSONField(default=list)
|
||||
```
|
||||
|
||||
### Payment Model
|
||||
|
||||
```python
|
||||
class Payment(AccountBaseModel):
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('succeeded', 'Succeeded'),
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded'),
|
||||
]
|
||||
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer'),
|
||||
('manual', 'Manual'),
|
||||
]
|
||||
|
||||
invoice = models.ForeignKey('Invoice', ...)
|
||||
amount = models.DecimalField(...)
|
||||
currency = models.CharField(max_length=3)
|
||||
payment_method = models.CharField(choices=PAYMENT_METHOD_CHOICES)
|
||||
status = models.CharField(choices=STATUS_CHOICES)
|
||||
stripe_payment_intent_id = models.CharField(...)
|
||||
paypal_order_id = models.CharField(...)
|
||||
manual_reference = models.CharField(..., unique=True)
|
||||
```
|
||||
|
||||
### WebhookEvent Model
|
||||
|
||||
```python
|
||||
class WebhookEvent(models.Model):
|
||||
"""Audit trail for webhook processing"""
|
||||
PROVIDER_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
]
|
||||
|
||||
provider = models.CharField(choices=PROVIDER_CHOICES)
|
||||
event_id = models.CharField(max_length=255)
|
||||
event_type = models.CharField(max_length=100)
|
||||
payload = models.JSONField()
|
||||
status = models.CharField() # 'pending', 'processed', 'failed'
|
||||
processed_at = models.DateTimeField(null=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### Implemented Security Measures
|
||||
|
||||
1. **Webhook Signature Verification**
|
||||
- Stripe: `stripe.Webhook.construct_event()` with signing secret
|
||||
- PayPal: `verify_webhook_signature()` API call
|
||||
|
||||
2. **Idempotency**
|
||||
- `WebhookEvent` model tracks processed events
|
||||
- Duplicate detection before processing
|
||||
|
||||
3. **Amount Validation**
|
||||
- PayPal capture validates amount matches expected
|
||||
- Prevents manipulation attacks
|
||||
|
||||
4. **Manual Reference Uniqueness**
|
||||
- Database constraint prevents duplicate bank transfer references
|
||||
- Prevents double submission
|
||||
|
||||
5. **CSRF Protection**
|
||||
- Webhook endpoints exempt (external callers)
|
||||
- All other endpoints protected
|
||||
|
||||
6. **Authentication**
|
||||
- Payment endpoints require `IsAuthenticatedAndActive`
|
||||
- Config endpoints allow `AllowAny` (public keys only)
|
||||
|
||||
---
|
||||
|
||||
## Admin Operations
|
||||
|
||||
### Django Admin Features
|
||||
|
||||
**Location:** Django Admin > Billing
|
||||
|
||||
- **Invoices:** View, filter, download PDF
|
||||
- **Payments:** View, approve/reject manual payments
|
||||
- **Credit Transactions:** Audit trail
|
||||
- **Credit Packages:** Manage packages
|
||||
|
||||
### Manual Payment Approval
|
||||
|
||||
```
|
||||
1. Admin navigates to Payments in Django Admin
|
||||
2. Filter by status='pending_approval'
|
||||
3. Review payment details and proof
|
||||
4. Click "Approve" or "Reject" action
|
||||
5. System automatically:
|
||||
- Updates payment status
|
||||
- Marks invoice paid (if approved)
|
||||
- Activates account (if approved)
|
||||
- Adds credits (if approved)
|
||||
- Sends email notification
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# PayPal
|
||||
PAYPAL_CLIENT_ID=...
|
||||
PAYPAL_CLIENT_SECRET=...
|
||||
PAYPAL_WEBHOOK_ID=...
|
||||
PAYPAL_MODE=sandbox|live
|
||||
|
||||
# General
|
||||
DEFAULT_CURRENCY=USD
|
||||
```
|
||||
|
||||
### IntegrationProvider Setup
|
||||
|
||||
Payment gateways configured via `IntegrationProvider` model in Django Admin:
|
||||
|
||||
1. **Stripe Provider:**
|
||||
- Name: "Stripe"
|
||||
- Provider Type: "stripe"
|
||||
- Credentials: `{"secret_key": "...", "publishable_key": "...", "webhook_secret": "..."}`
|
||||
|
||||
2. **PayPal Provider:**
|
||||
- Name: "PayPal"
|
||||
- Provider Type: "paypal"
|
||||
- Credentials: `{"client_id": "...", "client_secret": "...", "webhook_id": "..."}`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| "Stripe not configured" | Missing IntegrationProvider | Add Stripe provider in admin |
|
||||
| "PayPal not configured" | Missing IntegrationProvider | Add PayPal provider in admin |
|
||||
| PayPal shown for PK users | Country not passed correctly | Ensure `billing_country` saved on account |
|
||||
| Duplicate payments | Webhook retry without idempotency | Check `WebhookEvent` for duplicates |
|
||||
| PDF download fails | Missing `reportlab` | Run `pip install reportlab` |
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable billing debug logs:
|
||||
```python
|
||||
# settings.py
|
||||
LOGGING = {
|
||||
'loggers': {
|
||||
'igny8_core.business.billing': {
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
### Backend Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `billing/views/stripe_views.py` | Stripe API endpoints |
|
||||
| `billing/views/paypal_views.py` | PayPal API endpoints |
|
||||
| `billing/views/refund_views.py` | Refund processing |
|
||||
| `billing/services/stripe_service.py` | Stripe service layer |
|
||||
| `billing/services/paypal_service.py` | PayPal service layer |
|
||||
| `billing/services/invoice_service.py` | Invoice operations |
|
||||
| `billing/services/payment_service.py` | Payment operations |
|
||||
| `billing/services/pdf_service.py` | PDF generation |
|
||||
| `billing/services/email_service.py` | Email notifications |
|
||||
| `billing/models.py` | Billing models |
|
||||
| `billing/urls.py` | URL routing |
|
||||
|
||||
### Frontend Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `services/billing.api.ts` | API client functions |
|
||||
| `pages/account/PlansAndBillingPage.tsx` | Main billing page |
|
||||
| `components/billing/PendingPaymentView.tsx` | New user payment |
|
||||
| `components/billing/BankTransferForm.tsx` | Bank transfer form |
|
||||
| `components/billing/PayInvoiceModal.tsx` | Invoice payment modal |
|
||||
| `components/billing/PaymentGatewaySelector.tsx` | Gateway selection |
|
||||
| `components/billing/PendingPaymentBanner.tsx` | Payment alert banner |
|
||||
|
||||
---
|
||||
|
||||
*Document generated from production codebase - January 8, 2026*
|
||||
Reference in New Issue
Block a user