From 3028db51971429e05eeb5703b1190d7db51a8ce5 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Thu, 8 Jan 2026 00:36:32 +0000 Subject: [PATCH] Version 1.6.0 --- CHANGELOG.md | 172 +- docs/10-MODULES/BILLING.md | 6 +- .../PAYMENT-SYSTEM-ARCHITECTURE.md | 1120 -------- .../PAYMENT-SYSTEM-AUDIT-REPORT.md | 959 ------- .../PAYMENT-SYSTEM-REFACTOR-PLAN.md | 2365 ----------------- docs/90-REFERENCE/PAYMENT-SYSTEM.md | 677 +++++ docs/INDEX.md | 6 +- 7 files changed, 855 insertions(+), 4450 deletions(-) delete mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md delete mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md delete mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md create mode 100644 docs/90-REFERENCE/PAYMENT-SYSTEM.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be0ed17..43360ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # IGNY8 Change Log -**Current Version:** 1.4.0 -**Last Updated:** January 6, 2026 +**Current Version:** 1.6.0 +**Last Updated:** January 8, 2026 --- @@ -9,6 +9,7 @@ | Version | Date | Summary | |---------|------|---------| +| 1.6.0 | Jan 8, 2026 | **Major** - Payment System Refactor Complete: Stripe, PayPal, Bank Transfer flows finalized; Simplified signup (no payment redirect); Country-based payment rules; Webhook security; PDF invoices | | 1.5.0 | *Planned* | Image Generation System Overhaul, Quality Tier Refinements, Credit Service Enhancements | | 1.4.0 | Jan 5, 2026 | **Major** - AI Model Architecture Overhaul, IntegrationProvider Model, AIModelConfig with Credit System, SystemAISettings, Django Admin Reorganization | | 1.3.2 | Jan 3, 2026 | **Major** - Publishing Scheduler, Onboarding Wizard, Content Calendar, Design System Consolidation, Site Dashboard redesign | @@ -36,6 +37,173 @@ --- +## v1.6.0 - January 8, 2026 + +### Major Release: Payment System Refactor Complete + +This release completes the comprehensive refactoring of the payment system, including all three payment gateways (Stripe, PayPal, Bank Transfer), simplified signup flow, and enhanced security. + +--- + +### πŸ’³ Payment Gateway Integration + +**Stripe Integration:** +- Subscription checkout sessions +- Credit package checkout +- Billing portal for subscription management +- Webhook processing with idempotency +- Signature verification enforced + +**PayPal Integration:** +- One-time orders for credit packages +- Subscription orders for plans +- Order capture flow +- Webhook signature verification enabled +- Amount validation on capture + +**Bank Transfer (Pakistan):** +- Manual payment submission +- File upload for payment proof +- Admin approval workflow +- Email notifications on approval/rejection + +--- + +### πŸ”„ Simplified Signup Flow + +**Before:** Payment gateway redirect from signup page +**After:** Signup creates account only, payment on /account/plans + +**Changes:** +- Removed Stripe/PayPal checkout creation from registration +- Account created with `status='pending_payment'` for paid plans +- User redirected to Plans & Billing page to complete payment +- Single `/signup` route (removed `/signup/pk` variant) + +--- + +### 🌍 Country-Based Payment Rules + +**Global Users (non-PK):** +- Stripe (Credit/Debit Card) βœ… +- PayPal βœ… +- Bank Transfer ❌ + +**Pakistan Users (PK):** +- Stripe (Credit/Debit Card) βœ… +- PayPal ❌ (not available in Pakistan) +- Bank Transfer βœ… + +--- + +### 🎨 Frontend Components + +**New Components:** +- `PendingPaymentView` - Full-page payment interface for new users +- `BankTransferForm` - Bank transfer submission with proof upload +- `PaymentGatewaySelector` - Gateway selection UI + +**Updated Components:** +- `PlansAndBillingPage` - Conditional rendering based on user state +- `PendingPaymentBanner` - Alert for pending payments +- `PayInvoiceModal` - Invoice payment modal + +--- + +### πŸ”’ Security Enhancements + +- **Webhook Idempotency:** `WebhookEvent` model tracks processed events +- **PayPal Signature Verification:** Enabled and enforced +- **Stripe Signature Verification:** Already enforced +- **Amount Validation:** PayPal capture validates amount matches expected +- **Manual Reference Uniqueness:** Database constraint prevents duplicates + +--- + +### πŸ“„ PDF Invoice Generation + +- Added `reportlab` for professional PDF generation +- Logo integration from frontend assets +- Proper formatting (no HTML tags in output) +- Clean layout with company info, line items, totals +- Payment information section for paid invoices + +--- + +### πŸ—„οΈ Database Changes + +**New Migration:** `0029_add_webhook_event_and_manual_reference_constraint` + +**New Model:** `WebhookEvent` +```python +class WebhookEvent(models.Model): + provider = models.CharField(choices=['stripe', 'paypal']) + 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) +``` + +**Updated Model:** `Payment` +- Added unique constraint on `manual_reference` + +--- + +### πŸ“š Documentation + +**Consolidated:** Three payment documentation files merged into one: +- `PAYMENT-SYSTEM-ARCHITECTURE.md` β†’ Removed +- `PAYMENT-SYSTEM-AUDIT-REPORT.md` β†’ Removed +- `PAYMENT-SYSTEM-REFACTOR-PLAN.md` β†’ Removed + +**New:** `90-REFERENCE/PAYMENT-SYSTEM.md` +- Complete payment system documentation +- All flows, endpoints, models in one file +- Current state only (no planning artifacts) + +--- + +### πŸ“¦ Backend Files Changed + +| File | Changes | +|------|---------| +| `billing/views/stripe_views.py` | Idempotency, enhanced logging | +| `billing/views/paypal_views.py` | Signature verification, amount validation, idempotency | +| `billing/views/refund_views.py` | Fixed imports | +| `billing/services/stripe_service.py` | Full service implementation | +| `billing/services/paypal_service.py` | Full service implementation | +| `billing/services/pdf_service.py` | Professional PDF generation with logo | +| `billing/services/email_service.py` | Payment notification emails | +| `billing/models.py` | WebhookEvent model, payment method choices | +| `auth/serializers.py` | Removed checkout creation from registration | +| `auth/urls.py` | Added country list endpoint | + +--- + +### πŸ“± Frontend Files Changed + +| File | Changes | +|------|---------| +| `SignUpFormUnified.tsx` | Simplified, removed payment selection | +| `PlansAndBillingPage.tsx` | Conditional views, state handling | +| `PendingPaymentView.tsx` | New component for new user payments | +| `BankTransferForm.tsx` | New component for bank transfers | +| `PaymentGatewaySelector.tsx` | New component for gateway selection | +| `billing.api.ts` | Country-based gateway availability | +| `authStore.ts` | Removed checkout URL handling | + +--- + +### πŸ—‘οΈ Removed + +- `/signup/pk` route (consolidated into `/signup`) +- `SignUpPK.tsx` page component +- Payment gateway redirect from signup +- Three separate payment documentation files + +--- + ## v1.5.0 - Planned ### Upcoming Release: Image Generation System Overhaul diff --git a/docs/10-MODULES/BILLING.md b/docs/10-MODULES/BILLING.md index 4a40b049..d2323945 100644 --- a/docs/10-MODULES/BILLING.md +++ b/docs/10-MODULES/BILLING.md @@ -1,10 +1,12 @@ # Billing Module -**Last Verified:** January 6, 2026 -**Status:** βœ… Active (Simplified January 2026) +**Last Verified:** January 8, 2026 +**Status:** βœ… Active (Payment System Refactored January 2026) **Backend Path:** `backend/igny8_core/modules/billing/` + `backend/igny8_core/business/billing/` **Frontend Path:** `frontend/src/pages/Billing/` + `frontend/src/pages/Account/` +> **Payment System Reference:** For comprehensive payment gateway documentation (Stripe, PayPal, Bank Transfer), see [PAYMENT-SYSTEM.md](../90-REFERENCE/PAYMENT-SYSTEM.md) + --- ## Quick Reference diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md deleted file mode 100644 index b1a755d8..00000000 --- a/docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md +++ /dev/null @@ -1,1120 +0,0 @@ -# Complete Payment System Architecture - -> **Version:** 1.0 -> **Last Updated:** January 7, 2026 -> **Status:** Complete Documentation - -This document provides comprehensive documentation of the IGNY8 payment system architecture, including all entry points, services, models, and flows. - ---- - -## Table of Contents - -1. [System Overview](#system-overview) -2. [Payment Entry Points (Frontend)](#payment-entry-points-frontend) -3. [Frontend Payment Services](#frontend-payment-services) -4. [Backend Endpoints](#backend-endpoints) -5. [Backend Services](#backend-services) -6. [Models](#models) -7. [Payment Method Configuration](#payment-method-configuration) -8. [Payment Flows](#payment-flows) -9. [Webhooks](#webhooks) - ---- - -## 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 | -| **Local Wallet** | Manual | Configurable | Credit packages | - -### Payment Method Selection Logic - -- **Global Users**: Stripe (Card) + PayPal -- **Pakistan Users**: Stripe (Card) + Bank Transfer (NO PayPal) - ---- - -## Payment Entry Points (Frontend) - -### 1. SignUpFormUnified.tsx - -**File:** [frontend/src/components/auth/SignUpFormUnified.tsx](../../../frontend/src/components/auth/SignUpFormUnified.tsx) - -**Purpose:** Handles payment method selection and checkout redirect during user registration. - -#### Key Features (Lines 1-770) - -``` -- Payment Method Loading: Lines 105-195 -- Form Submission with Payment: Lines 213-344 -- Payment Gateway Redirect Logic: Lines 315-340 -``` - -#### Payment Flow: - -1. **Loads available payment methods** from `/v1/billing/payment-configs/payment-methods/` (Lines 110-195) -2. **Determines available options** based on country: - - Pakistan (`/signup/pk`): Stripe + Bank Transfer - - Global (`/signup`): Stripe + PayPal -3. **On form submit** (Lines 213-344): - - Calls `register()` from authStore - - Backend returns `checkout_url` for Stripe/PayPal - - Redirects to payment gateway OR plans page (bank transfer) - -#### Data Passed to Backend: - -```typescript -{ - email, password, username, first_name, last_name, - account_name, - plan_slug: selectedPlan.slug, - payment_method: selectedPaymentMethod, // 'stripe' | 'paypal' | 'bank_transfer' - billing_email, - billing_country -} -``` - ---- - -### 2. PlansAndBillingPage.tsx - -**File:** [frontend/src/pages/account/PlansAndBillingPage.tsx](../../../frontend/src/pages/account/PlansAndBillingPage.tsx) - -**Purpose:** Comprehensive billing dashboard for subscription management, credit purchases, and payment history. - -#### Key Features (Lines 1-1197) - -``` -- Payment Gateway Return Handler: Lines 100-155 -- Plan Selection (handleSelectPlan): Lines 283-310 -- Credit Purchase (handlePurchaseCredits): Lines 330-352 -- Billing Portal (handleManageSubscription): Lines 354-361 -- Invoice Download: Lines 363-376 -``` - -#### Payment Flows: - -**1. Plan Upgrade (handleSelectPlan - Lines 283-310):** -```typescript -// For Stripe/PayPal: -const { redirect_url } = await subscribeToPlan(planId.toString(), selectedGateway); -window.location.href = redirect_url; - -// For Manual/Bank Transfer: -await createSubscription({ plan_id: planId, payment_method: selectedPaymentMethod }); -``` - -**2. Credit Purchase (handlePurchaseCredits - Lines 330-352):** -```typescript -// For Stripe/PayPal: -const { redirect_url } = await purchaseCredits(packageId.toString(), selectedGateway); -window.location.href = redirect_url; - -// For Manual/Bank Transfer: -await purchaseCreditPackage({ package_id: packageId, payment_method: selectedPaymentMethod }); -``` - -**3. Return URL Handling (Lines 100-155):** -```typescript -// PayPal Success - capture order -if (paypalStatus === 'success' && paypalToken) { - await capturePayPalOrder(paypalToken, { plan_id, package_id }); -} -// Stripe Success -if (success === 'true') { - toast.success('Subscription activated!'); - await refreshUser(); -} -``` - ---- - -### 3. PayInvoiceModal.tsx - -**File:** [frontend/src/components/billing/PayInvoiceModal.tsx](../../../frontend/src/components/billing/PayInvoiceModal.tsx) - -**Purpose:** Modal for paying pending invoices with multiple payment methods. - -#### Key Features (Lines 1-544) - -``` -- Payment Method Selection: Lines 55-95 -- Stripe Payment Handler: Lines 115-140 -- PayPal Payment Handler: Lines 142-167 -- Bank Transfer Form: Lines 203-250 -``` - -#### Payment Method Logic (Lines 55-95): - -```typescript -const isPakistan = userCountry?.toUpperCase() === 'PK'; - -// Available options based on country: -const availableOptions: PaymentOption[] = isPakistan - ? ['stripe', 'bank_transfer'] - : ['stripe', 'paypal']; -``` - -#### Stripe Payment (Lines 115-140): - -```typescript -const handleStripePayment = async () => { - const result = await subscribeToPlan(planIdentifier, '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; -}; -``` - -#### Bank Transfer Submission (Lines 203-250): - -```typescript -const handleBankSubmit = async () => { - await fetch(`${API_BASE_URL}/v1/billing/admin/payments/confirm/`, { - method: 'POST', - body: JSON.stringify({ - invoice_id: invoice.id, - payment_method: 'bank_transfer', - amount: invoice.total_amount, - manual_reference: bankFormData.manual_reference, - manual_notes: bankFormData.manual_notes, - proof_url: bankFormData.proof_url, - }), - }); -}; -``` - ---- - -### 4. PendingPaymentBanner.tsx - -**File:** [frontend/src/components/billing/PendingPaymentBanner.tsx](../../../frontend/src/components/billing/PendingPaymentBanner.tsx) - -**Purpose:** Alert banner displayed when account status is `pending_payment`. - -#### Key Features (Lines 1-338) - -``` -- Account Status Check: Lines 45-48 -- Pending Invoice Loading: Lines 60-130 -- Payment Gateway Detection: Lines 100-125 -- Pay Invoice Modal Trigger: Line 160 -``` - -#### Display Logic: - -```typescript -const accountStatus = user?.account?.status; -const isPendingPayment = accountStatus === 'pending_payment'; -``` - -#### Loads: - -1. Pending invoices from `/v1/billing/invoices/?status=pending&limit=1` -2. Account payment methods from `/v1/billing/payment-methods/` -3. Available gateways from `/v1/billing/stripe/config/` and `/v1/billing/paypal/config/` - ---- - -## Frontend Payment Services - -### billing.api.ts - -**File:** [frontend/src/services/billing.api.ts](../../../frontend/src/services/billing.api.ts) - -**Total Lines:** 1443 - -#### Key Payment Functions: - -| Function | Lines | Endpoint | Description | -|----------|-------|----------|-------------| -| `getStripeConfig` | 1095-1097 | GET `/v1/billing/stripe/config/` | Get Stripe publishable key | -| `createStripeCheckout` | 1102-1112 | POST `/v1/billing/stripe/checkout/` | Create checkout session | -| `createStripeCreditCheckout` | 1200-1215 | POST `/v1/billing/stripe/credit-checkout/` | Credit package checkout | -| `openStripeBillingPortal` | 1220-1230 | POST `/v1/billing/stripe/billing-portal/` | Subscription management | -| `getPayPalConfig` | 1270-1272 | GET `/v1/billing/paypal/config/` | Get PayPal client ID | -| `createPayPalCreditOrder` | 1278-1290 | POST `/v1/billing/paypal/create-order/` | Credit package order | -| `createPayPalSubscriptionOrder` | 1295-1307 | POST `/v1/billing/paypal/create-subscription-order/` | Subscription order | -| `capturePayPalOrder` | 1312-1325 | POST `/v1/billing/paypal/capture-order/` | Capture approved order | -| `subscribeToPlan` | 1365-1380 | Gateway-specific | Unified subscribe helper | -| `purchaseCredits` | 1385-1400 | Gateway-specific | Unified purchase helper | -| `getInvoices` | 480-492 | GET `/v1/billing/invoices/` | List invoices | -| `getPayments` | 510-522 | GET `/v1/billing/payments/` | List payments | -| `submitManualPayment` | 528-542 | POST `/v1/billing/payments/manual/` | Submit bank transfer | -| `getAvailablePaymentMethods` | 648-665 | GET `/v1/billing/payment-configs/payment-methods/` | Get payment configs | - -#### Helper Functions (Lines 1355-1443): - -```typescript -// Subscribe to plan using preferred gateway -export async function subscribeToPlan( - planId: string, - gateway: PaymentGateway, - options?: { return_url?: string; cancel_url?: string } -): Promise<{ redirect_url: string }> { - switch (gateway) { - case 'stripe': return await createStripeCheckout(planId, options); - case 'paypal': return await createPayPalSubscriptionOrder(planId, options); - case 'manual': throw new Error('Use submitManualPayment()'); - } -} - -// Purchase credit package using preferred gateway -export async function purchaseCredits( - packageId: string, - gateway: PaymentGateway, - options?: { return_url?: string; cancel_url?: string } -): Promise<{ redirect_url: string }> { - switch (gateway) { - case 'stripe': return await createStripeCreditCheckout(packageId, options); - case 'paypal': return await createPayPalCreditOrder(packageId, options); - case 'manual': throw new Error('Use submitManualPayment()'); - } -} -``` - ---- - -### authStore.ts - -**File:** [frontend/src/store/authStore.ts](../../../frontend/src/store/authStore.ts) - -**Total Lines:** 534 - -#### Register Function (Lines 230-395): - -```typescript -register: async (registerData) => { - const response = await fetch(`${API_BASE_URL}/v1/auth/register/`, { - method: 'POST', - body: JSON.stringify({ - ...registerData, - password_confirm: registerData.password, - plan_slug: registerData.plan_slug, - }), - }); - - const data = await response.json(); - - // Extract checkout_url for payment redirect - return { - ...userData, - checkout_url: responseData.checkout_url || data.checkout_url, - checkout_session_id: responseData.checkout_session_id, - paypal_order_id: responseData.paypal_order_id, - }; -} -``` - -**Key Return Fields:** -- `checkout_url` - Stripe/PayPal redirect URL -- `checkout_session_id` - Stripe session ID -- `paypal_order_id` - PayPal order ID - ---- - -## Backend Endpoints - -### Billing URLs - -**File:** [backend/igny8_core/business/billing/urls.py](../../../backend/igny8_core/business/billing/urls.py) - -```python -# Stripe Endpoints -path('stripe/config/', StripeConfigView.as_view()) -path('stripe/checkout/', StripeCheckoutView.as_view()) -path('stripe/credit-checkout/', StripeCreditCheckoutView.as_view()) -path('stripe/billing-portal/', StripeBillingPortalView.as_view()) -path('webhooks/stripe/', stripe_webhook) - -# PayPal Endpoints -path('paypal/config/', PayPalConfigView.as_view()) -path('paypal/create-order/', PayPalCreateOrderView.as_view()) -path('paypal/create-subscription-order/', PayPalCreateSubscriptionOrderView.as_view()) -path('paypal/capture-order/', PayPalCaptureOrderView.as_view()) -path('paypal/create-subscription/', PayPalCreateSubscriptionView.as_view()) -path('webhooks/paypal/', paypal_webhook) - -# Router ViewSets -router.register(r'invoices', InvoiceViewSet) -router.register(r'payments', PaymentViewSet) -router.register(r'credit-packages', CreditPackageViewSet) -router.register(r'payment-methods', AccountPaymentMethodViewSet) -router.register(r'payment-configs', BillingViewSet) -``` - ---- - -### Stripe Views - -**File:** [backend/igny8_core/business/billing/views/stripe_views.py](../../../backend/igny8_core/business/billing/views/stripe_views.py) - -**Total Lines:** 802 - -| View | Lines | Method | Endpoint | Description | -|------|-------|--------|----------|-------------| -| `StripeConfigView` | 35-55 | GET | `/stripe/config/` | Returns publishable key | -| `StripeCheckoutView` | 58-125 | POST | `/stripe/checkout/` | Creates subscription checkout | -| `StripeCreditCheckoutView` | 128-200 | POST | `/stripe/credit-checkout/` | Creates credit checkout | -| `StripeBillingPortalView` | 203-260 | POST | `/stripe/billing-portal/` | Creates portal session | -| `stripe_webhook` | 263-350 | POST | `/webhooks/stripe/` | Handles Stripe webhooks | - ---- - -### PayPal Views - -**File:** [backend/igny8_core/business/billing/views/paypal_views.py](../../../backend/igny8_core/business/billing/views/paypal_views.py) - -**Total Lines:** 910 - -| View | Lines | Method | Endpoint | Description | -|------|-------|--------|----------|-------------| -| `PayPalConfigView` | 42-58 | GET | `/paypal/config/` | Returns client ID | -| `PayPalCreateOrderView` | 61-135 | POST | `/paypal/create-order/` | Creates credit order | -| `PayPalCreateSubscriptionOrderView` | 138-230 | POST | `/paypal/create-subscription-order/` | Creates subscription order | -| `PayPalCaptureOrderView` | 233-400 | POST | `/paypal/capture-order/` | Captures approved order | -| `PayPalCreateSubscriptionView` | 403-500 | POST | `/paypal/create-subscription/` | Creates recurring subscription | -| `paypal_webhook` | 503-700 | POST | `/webhooks/paypal/` | Handles PayPal webhooks | - ---- - -### Auth Register Endpoint - -**File:** [backend/igny8_core/auth/urls.py](../../../backend/igny8_core/auth/urls.py) (Lines 40-170) - -**Also:** [backend/igny8_core/auth/serializers.py](../../../backend/igny8_core/auth/serializers.py) (Lines 280-500) - -#### RegisterSerializer.create() Flow (Lines 340-500): - -1. **Plan Lookup** (Lines 340-365): - ```python - if plan_slug in ['starter', 'growth', 'scale']: - plan = Plan.objects.get(slug=plan_slug, is_active=True) - account_status = 'pending_payment' - initial_credits = 0 - else: - plan = Plan.objects.get(slug='free', is_active=True) - account_status = 'trial' - initial_credits = plan.included_credits - ``` - -2. **Account Creation** (Lines 390-420): - ```python - account = Account.objects.create( - name=account_name, - owner=user, - plan=plan, - credits=initial_credits, - status=account_status, # 'pending_payment' or 'trial' - payment_method=validated_data.get('payment_method', 'bank_transfer'), - billing_country=validated_data.get('billing_country', ''), - ) - ``` - -3. **For Paid Plans** (Lines 445-485): - ```python - # Create Subscription - subscription = Subscription.objects.create( - account=account, - plan=plan, - status='pending_payment', - current_period_start=billing_period_start, - current_period_end=billing_period_end, - ) - - # Create Invoice - InvoiceService.create_subscription_invoice(subscription, ...) - - # Create AccountPaymentMethod - AccountPaymentMethod.objects.create( - account=account, - type=payment_method, - is_default=True, - is_verified=False, - ) - ``` - -#### Checkout Session Creation (RegisterView - Lines 118-165): - -```python -# For Stripe -if payment_method == 'stripe': - checkout_data = stripe_service.create_checkout_session( - account=account, - plan=account.plan, - ) - response_data['checkout_url'] = checkout_data['checkout_url'] - -# For PayPal -elif payment_method == 'paypal': - order = paypal_service.create_order( - account=account, - amount=float(account.plan.price), - description=f'{account.plan.name} Plan Subscription', - ) - response_data['checkout_url'] = order['approval_url'] -``` - ---- - -## Backend Services - -### StripeService - -**File:** [backend/igny8_core/business/billing/services/stripe_service.py](../../../backend/igny8_core/business/billing/services/stripe_service.py) - -**Total Lines:** 628 - -#### Key Methods: - -| Method | Lines | Description | -|--------|-------|-------------| -| `__init__` | 28-60 | Initialize from IntegrationProvider | -| `_get_or_create_customer` | 73-110 | Get/create Stripe customer | -| `create_checkout_session` | 130-210 | Create subscription checkout | -| `create_credit_checkout_session` | 215-280 | Create credit package checkout | -| `create_billing_portal_session` | 330-375 | Create portal session | -| `get_subscription` | 380-420 | Get subscription details | -| `cancel_subscription` | 425-450 | Cancel subscription | -| `construct_webhook_event` | 485-500 | Verify webhook signature | -| `create_refund` | 555-600 | Create refund | - -#### Checkout Session Flow: - -```python -def create_checkout_session(self, account, plan, success_url=None, cancel_url=None): - customer_id = self._get_or_create_customer(account) - - session = stripe.checkout.Session.create( - customer=customer_id, - payment_method_types=['card'], - mode='subscription', - line_items=[{'price': plan.stripe_price_id, 'quantity': 1}], - success_url=success_url or f'{self.frontend_url}/account/plans?success=true', - cancel_url=cancel_url or f'{self.frontend_url}/account/plans?canceled=true', - metadata={ - 'account_id': str(account.id), - 'plan_id': str(plan.id), - 'type': 'subscription', - }, - ) - - return {'checkout_url': session.url, 'session_id': session.id} -``` - ---- - -### PayPalService - -**File:** [backend/igny8_core/business/billing/services/paypal_service.py](../../../backend/igny8_core/business/billing/services/paypal_service.py) - -**Total Lines:** 680 - -#### Key Methods: - -| Method | Lines | Description | -|--------|-------|-------------| -| `__init__` | 44-85 | Initialize from IntegrationProvider | -| `_get_access_token` | 105-140 | Get OAuth token | -| `_make_request` | 145-195 | Make authenticated request | -| `create_order` | 220-275 | Create one-time order | -| `create_credit_order` | 300-340 | Create credit package order | -| `capture_order` | 345-400 | Capture approved order | -| `create_subscription` | 420-480 | Create recurring subscription | -| `cancel_subscription` | 510-535 | Cancel subscription | -| `verify_webhook_signature` | 565-610 | Verify webhook | -| `refund_capture` | 615-660 | Refund payment | - -#### Order Flow: - -```python -def create_order(self, account, amount, description='', return_url=None, cancel_url=None): - order_data = { - 'intent': 'CAPTURE', - 'purchase_units': [{ - 'amount': {'currency_code': self.currency, 'value': f'{amount:.2f}'}, - 'description': description or 'IGNY8 Payment', - 'custom_id': str(account.id), - }], - 'application_context': { - 'return_url': return_url or self.return_url, - 'cancel_url': cancel_url or self.cancel_url, - 'brand_name': 'IGNY8', - 'user_action': 'PAY_NOW', - } - } - - response = self._make_request('POST', '/v2/checkout/orders', json_data=order_data) - - return { - 'order_id': response.get('id'), - 'status': response.get('status'), - 'approval_url': approval_url, # From links - } -``` - ---- - -### InvoiceService - -**File:** [backend/igny8_core/business/billing/services/invoice_service.py](../../../backend/igny8_core/business/billing/services/invoice_service.py) - -**Total Lines:** 385 - -#### Key Methods: - -| Method | Lines | Description | -|--------|-------|-------------| -| `get_pending_invoice` | 18-27 | Get pending invoice for subscription | -| `get_or_create_subscription_invoice` | 30-50 | Avoid duplicate invoices | -| `generate_invoice_number` | 53-80 | Generate unique invoice number | -| `create_subscription_invoice` | 85-165 | Create subscription invoice | -| `create_credit_package_invoice` | 170-230 | Create credit package invoice | -| `mark_paid` | 260-290 | Mark invoice as paid | -| `mark_void` | 295-310 | Void invoice | - -#### Currency Logic: - -```python -# Online payments (stripe, paypal): Always USD -# Manual payments (bank_transfer, local_wallet): Local currency - -if payment_method in ['stripe', 'paypal']: - currency = 'USD' - local_price = float(plan.price) -else: - currency = get_currency_for_country(account.billing_country) # 'PKR' for PK - local_price = convert_usd_to_local(float(plan.price), account.billing_country) -``` - ---- - -### PaymentService - -**File:** [backend/igny8_core/business/billing/services/payment_service.py](../../../backend/igny8_core/business/billing/services/payment_service.py) - -**Total Lines:** 418 - -#### Key Methods: - -| Method | Lines | Description | -|--------|-------|-------------| -| `create_stripe_payment` | 17-35 | Record Stripe payment | -| `create_paypal_payment` | 40-55 | Record PayPal payment | -| `create_manual_payment` | 60-92 | Record manual payment (pending_approval) | -| `mark_payment_completed` | 97-130 | Mark as succeeded | -| `mark_payment_failed` | 135-150 | Mark as failed | -| `approve_manual_payment` | 155-200 | Admin approves manual payment | -| `reject_manual_payment` | 205-230 | Admin rejects manual payment | -| `_add_credits_for_payment` | 235-270 | Add credits after payment | -| `get_available_payment_methods` | 275-340 | Get methods by country | - -#### Manual Payment Approval: - -```python -def approve_manual_payment(payment, approved_by_user_id, admin_notes=None): - payment.status = 'succeeded' - payment.approved_by_id = approved_by_user_id - payment.approved_at = timezone.now() - payment.save() - - # Update invoice - InvoiceService.mark_paid(payment.invoice, payment_method=payment.payment_method) - - # Add credits if credit package - if payment.metadata.get('credit_package_id'): - PaymentService._add_credits_for_payment(payment) - - # Activate account - if account.status != 'active': - account.status = 'active' - account.save() -``` - ---- - -## Models - -### Account Model - -**File:** [backend/igny8_core/auth/models.py](../../../backend/igny8_core/auth/models.py) (Lines 55-145) - -```python -class Account(SoftDeletableModel): - STATUS_CHOICES = [ - ('active', 'Active'), - ('suspended', 'Suspended'), - ('trial', 'Trial'), - ('cancelled', 'Cancelled'), - ('pending_payment', 'Pending Payment'), - ] - - PAYMENT_METHOD_CHOICES = [ - ('stripe', 'Stripe'), - ('paypal', 'PayPal'), - ('bank_transfer', 'Bank Transfer'), - ] - - name = models.CharField(max_length=255) - slug = models.SlugField(unique=True) - owner = models.ForeignKey('User', on_delete=models.SET_NULL, null=True) - stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) - plan = models.ForeignKey('Plan', on_delete=models.PROTECT) - credits = models.IntegerField(default=0) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial') - payment_method = models.CharField(max_length=30, choices=PAYMENT_METHOD_CHOICES, default='stripe') - - # Billing information - billing_email = models.EmailField(blank=True, null=True) - billing_address_line1 = models.CharField(max_length=255, blank=True) - billing_country = models.CharField(max_length=2, blank=True) # ISO country code - tax_id = models.CharField(max_length=100, blank=True) -``` - ---- - -### Subscription Model - -**File:** [backend/igny8_core/auth/models.py](../../../backend/igny8_core/auth/models.py) (Lines 395-440) - -```python -class Subscription(models.Model): - STATUS_CHOICES = [ - ('active', 'Active'), - ('past_due', 'Past Due'), - ('canceled', 'Canceled'), - ('trialing', 'Trialing'), - ('pending_payment', 'Pending Payment'), - ] - - account = models.OneToOneField('Account', on_delete=models.CASCADE, related_name='subscription') - plan = models.ForeignKey('Plan', on_delete=models.PROTECT) - stripe_subscription_id = models.CharField(max_length=255, blank=True, null=True) - external_payment_id = models.CharField(max_length=255, blank=True, null=True) # PayPal/Bank ref - status = models.CharField(max_length=20, choices=STATUS_CHOICES) - current_period_start = models.DateTimeField() - current_period_end = models.DateTimeField() - cancel_at_period_end = models.BooleanField(default=False) -``` - ---- - -### Plan Model - -**File:** [backend/igny8_core/auth/models.py](../../../backend/igny8_core/auth/models.py) (Lines 300-395) - -```python -class Plan(models.Model): - name = models.CharField(max_length=255) - slug = models.SlugField(unique=True) - price = models.DecimalField(max_digits=10, decimal_places=2) - billing_cycle = models.CharField(max_length=20, choices=[('monthly', 'Monthly'), ('annual', 'Annual')]) - is_active = models.BooleanField(default=True) - is_internal = models.BooleanField(default=False) # Hidden from public (Free plan) - - # Limits - max_users = models.IntegerField(default=1) - max_sites = models.IntegerField(default=1) - max_keywords = models.IntegerField(default=1000) - - # Credits - included_credits = models.IntegerField(default=0) - - # Payment Integration - stripe_product_id = models.CharField(max_length=255, blank=True, null=True) - stripe_price_id = models.CharField(max_length=255, blank=True, null=True) -``` - ---- - -### Invoice Model - -**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 310-380) - -```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('Subscription', on_delete=models.SET_NULL, null=True) - subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0) - tax = models.DecimalField(max_digits=10, decimal_places=2, default=0) - total = models.DecimalField(max_digits=10, decimal_places=2, default=0) - currency = models.CharField(max_length=3, default='USD') - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') - invoice_date = models.DateField() - due_date = models.DateField() - paid_at = models.DateTimeField(null=True, blank=True) - line_items = models.JSONField(default=list) - stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True) - payment_method = models.CharField(max_length=50, null=True, blank=True) - metadata = models.JSONField(default=dict) -``` - ---- - -### Payment Model - -**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 460-540) - -```python -class Payment(AccountBaseModel): - STATUS_CHOICES = [ - ('pending_approval', 'Pending Approval'), # Manual payment awaiting admin - ('succeeded', 'Succeeded'), - ('failed', 'Failed'), - ('refunded', 'Refunded'), - ] - - PAYMENT_METHOD_CHOICES = [ - ('stripe', 'Stripe'), - ('paypal', 'PayPal'), - ('bank_transfer', 'Bank Transfer'), - ('local_wallet', 'Local Wallet'), - ('manual', 'Manual Payment'), - ] - - invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE) - amount = models.DecimalField(max_digits=10, decimal_places=2) - currency = models.CharField(max_length=3, default='USD') - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_approval') - payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) - - # Stripe - stripe_payment_intent_id = models.CharField(max_length=255, null=True, blank=True) - stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) - - # PayPal - paypal_order_id = models.CharField(max_length=255, null=True, blank=True) - paypal_capture_id = models.CharField(max_length=255, null=True, blank=True) - - # Manual - manual_reference = models.CharField(max_length=255, blank=True) # Bank transfer ref - manual_notes = models.TextField(blank=True) - admin_notes = models.TextField(blank=True) - approved_by = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL) - approved_at = models.DateTimeField(null=True, blank=True) - - metadata = models.JSONField(default=dict) -``` - ---- - -### AccountPaymentMethod Model - -**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 700-750) - -```python -class AccountPaymentMethod(AccountBaseModel): - """Account-scoped payment methods (user's verified payment options)""" - PAYMENT_METHOD_CHOICES = [ - ('stripe', 'Stripe'), - ('paypal', 'PayPal'), - ('bank_transfer', 'Bank Transfer'), - ('local_wallet', 'Local Wallet'), - ('manual', 'Manual Payment'), - ] - - type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) - display_name = models.CharField(max_length=100) - is_default = models.BooleanField(default=False) - is_enabled = models.BooleanField(default=True) - is_verified = models.BooleanField(default=False) # True after successful payment - country_code = models.CharField(max_length=2, blank=True) - instructions = models.TextField(blank=True) - metadata = models.JSONField(default=dict) -``` - ---- - -### PaymentMethodConfig Model - -**File:** [backend/igny8_core/business/billing/models.py](../../../backend/igny8_core/business/billing/models.py) (Lines 600-695) - -```python -class PaymentMethodConfig(models.Model): - """System-level payment method configuration per country""" - PAYMENT_METHOD_CHOICES = [ - ('stripe', 'Stripe'), - ('paypal', 'PayPal'), - ('bank_transfer', 'Bank Transfer'), - ('local_wallet', 'Local Wallet'), - ('manual', 'Manual Payment'), - ] - - country_code = models.CharField(max_length=2) # 'US', 'PK', '*' for global - payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) - is_enabled = models.BooleanField(default=True) - display_name = models.CharField(max_length=100, blank=True) - instructions = models.TextField(blank=True) - - # Bank Transfer Details - bank_name = models.CharField(max_length=255, blank=True) - account_number = models.CharField(max_length=255, blank=True) - account_title = models.CharField(max_length=255, blank=True) - routing_number = models.CharField(max_length=255, blank=True) - swift_code = models.CharField(max_length=255, blank=True) - iban = models.CharField(max_length=255, blank=True) - - # Local Wallet Details - wallet_type = models.CharField(max_length=100, blank=True) # JazzCash, EasyPaisa - wallet_id = models.CharField(max_length=255, blank=True) - - sort_order = models.IntegerField(default=0) - - class Meta: - unique_together = [['country_code', 'payment_method']] -``` - ---- - -## Payment Method Configuration - -### Country-Based Payment Methods - -| Country | Stripe | PayPal | Bank Transfer | Local Wallet | -|---------|--------|--------|---------------|--------------| -| Global (`*`) | βœ… | βœ… | ❌ | ❌ | -| Pakistan (`PK`) | βœ… | ❌ | βœ… | βœ… (JazzCash, EasyPaisa) | - -### Configuration Flow - -1. **PaymentMethodConfig** stores system-level configs per country -2. **AccountPaymentMethod** stores user's saved/verified methods -3. Frontend queries `/v1/billing/payment-configs/payment-methods/` to get available options -4. Frontend filters based on user's country (from `countryCode` prop or account's `billing_country`) - ---- - -## Payment Flows - -### Flow 1: Signup with Stripe - -```mermaid -sequenceDiagram - participant U as User - participant F as Frontend - participant B as Backend - participant S as Stripe - - U->>F: Fill signup form, select Stripe - F->>B: POST /v1/auth/register/ {payment_method: 'stripe', plan_slug: 'starter'} - B->>B: Create User, Account (status=pending_payment), Subscription, Invoice - B->>S: Create Checkout Session - S-->>B: checkout_url - B-->>F: {user, tokens, checkout_url} - F->>S: Redirect to checkout_url - U->>S: Complete payment - S->>B: POST /webhooks/stripe/ (checkout.session.completed) - B->>B: Activate subscription, mark invoice paid, add credits - S->>F: Redirect to success_url - F->>B: GET /v1/auth/me/ (refresh user) -``` - -### Flow 2: Signup with Bank Transfer - -```mermaid -sequenceDiagram - participant U as User - participant F as Frontend - participant B as Backend - participant A as Admin - - U->>F: Fill signup form, select Bank Transfer - F->>B: POST /v1/auth/register/ {payment_method: 'bank_transfer'} - B->>B: Create User, Account (status=pending_payment), Subscription, Invoice, AccountPaymentMethod - B-->>F: {user, tokens} - F->>F: Navigate to /account/plans (shows PendingPaymentBanner) - U->>F: Make bank transfer, enter reference - F->>B: POST /v1/billing/admin/payments/confirm/ {invoice_id, manual_reference} - B->>B: Create Payment (status=pending_approval) - A->>B: POST /v1/admin/billing/{id}/approve_payment/ - B->>B: Mark payment succeeded, invoice paid, activate account, add credits -``` - -### Flow 3: Credit Purchase with PayPal - -```mermaid -sequenceDiagram - participant U as User - participant F as Frontend - participant B as Backend - participant P as PayPal - - U->>F: Click Buy Credits, select PayPal - F->>B: POST /v1/billing/paypal/create-order/ {package_id} - B->>P: Create Order - P-->>B: {order_id, approval_url} - B-->>F: {order_id, approval_url} - F->>P: Redirect to approval_url - U->>P: Approve payment - P->>F: Redirect to return_url with token - F->>B: POST /v1/billing/paypal/capture-order/ {order_id} - B->>P: Capture Order - P-->>B: {capture_id, status: COMPLETED} - B->>B: Create invoice, payment, add credits - B-->>F: {credits_added, new_balance} -``` - -### Flow 4: Pay Pending Invoice (PayInvoiceModal) - -```mermaid -sequenceDiagram - participant U as User - participant F as Frontend - participant B as Backend - participant S as Stripe - - U->>F: Click "Pay Invoice" button - F->>F: Open PayInvoiceModal - U->>F: Select Stripe, click Pay - F->>B: POST /v1/billing/stripe/checkout/ {plan_id} - B->>S: Create Checkout Session - S-->>B: checkout_url - B-->>F: {checkout_url} - F->>S: Redirect to checkout_url - U->>S: Complete payment - S->>B: Webhook: checkout.session.completed - B->>B: Update subscription, invoice, account status -``` - ---- - -## Webhooks - -### Stripe Webhooks - -**Endpoint:** `POST /v1/billing/webhooks/stripe/` - -| Event | Handler | Action | -|-------|---------|--------| -| `checkout.session.completed` | `_handle_checkout_completed` | Activate subscription OR add purchased credits | -| `invoice.paid` | `_handle_invoice_paid` | Add monthly credits for renewal | -| `invoice.payment_failed` | `_handle_payment_failed` | Send notification, update status | -| `customer.subscription.updated` | `_handle_subscription_updated` | Sync plan changes | -| `customer.subscription.deleted` | `_handle_subscription_deleted` | Cancel subscription | - -#### Checkout Completed Handler (Lines 355-475): - -```python -def _handle_checkout_completed(session): - metadata = session.get('metadata', {}) - account_id = metadata.get('account_id') - mode = session.get('mode') - - if mode == 'subscription': - _activate_subscription(account, subscription_id, plan_id, session) - elif mode == 'payment': - _add_purchased_credits(account, credit_package_id, credit_amount, session) -``` - -### PayPal Webhooks - -**Endpoint:** `POST /v1/billing/webhooks/paypal/` - -| Event | Action | -|-------|--------| -| `CHECKOUT.ORDER.APPROVED` | Auto-capture (if configured) | -| `PAYMENT.CAPTURE.COMPLETED` | Mark payment succeeded, add credits | -| `PAYMENT.CAPTURE.DENIED` | Mark payment failed | -| `BILLING.SUBSCRIPTION.ACTIVATED` | Activate subscription | -| `BILLING.SUBSCRIPTION.CANCELLED` | Cancel subscription | -| `BILLING.SUBSCRIPTION.PAYMENT.FAILED` | Handle failed renewal | - ---- - -## Summary - -### Key Files Reference - -| Category | File | Lines | -|----------|------|-------| -| **Frontend Entry Points** | | | -| Signup Form | `frontend/src/components/auth/SignUpFormUnified.tsx` | 770 | -| Plans & Billing Page | `frontend/src/pages/account/PlansAndBillingPage.tsx` | 1197 | -| Pay Invoice Modal | `frontend/src/components/billing/PayInvoiceModal.tsx` | 544 | -| Pending Payment Banner | `frontend/src/components/billing/PendingPaymentBanner.tsx` | 338 | -| **Frontend Services** | | | -| Billing API | `frontend/src/services/billing.api.ts` | 1443 | -| Auth Store | `frontend/src/store/authStore.ts` | 534 | -| **Backend Views** | | | -| Stripe Views | `backend/igny8_core/business/billing/views/stripe_views.py` | 802 | -| PayPal Views | `backend/igny8_core/business/billing/views/paypal_views.py` | 910 | -| **Backend Services** | | | -| Stripe Service | `backend/igny8_core/business/billing/services/stripe_service.py` | 628 | -| PayPal Service | `backend/igny8_core/business/billing/services/paypal_service.py` | 680 | -| Invoice Service | `backend/igny8_core/business/billing/services/invoice_service.py` | 385 | -| Payment Service | `backend/igny8_core/business/billing/services/payment_service.py` | 418 | -| **Models** | | | -| Auth Models | `backend/igny8_core/auth/models.py` | 866 | -| Billing Models | `backend/igny8_core/business/billing/models.py` | 857 | -| **Serializers** | | | -| Auth Serializers | `backend/igny8_core/auth/serializers.py` | 561 | -| **URLs** | | | -| Billing URLs | `backend/igny8_core/business/billing/urls.py` | 73 | -| Auth URLs | `backend/igny8_core/auth/urls.py` | 461 | - ---- - -## Quick Reference: API Endpoints - -### Stripe - -``` -GET /v1/billing/stripe/config/ - Get publishable key -POST /v1/billing/stripe/checkout/ - Create subscription checkout -POST /v1/billing/stripe/credit-checkout/ - Create credit checkout -POST /v1/billing/stripe/billing-portal/ - Open billing portal -POST /v1/billing/webhooks/stripe/ - Webhook handler -``` - -### PayPal - -``` -GET /v1/billing/paypal/config/ - Get client ID -POST /v1/billing/paypal/create-order/ - Create credit order -POST /v1/billing/paypal/create-subscription-order/ - Create subscription order -POST /v1/billing/paypal/capture-order/ - Capture approved order -POST /v1/billing/paypal/create-subscription/ - Create recurring subscription -POST /v1/billing/webhooks/paypal/ - Webhook handler -``` - -### Invoices & Payments - -``` -GET /v1/billing/invoices/ - List invoices -GET /v1/billing/invoices/{id}/ - Get invoice detail -GET /v1/billing/invoices/{id}/download_pdf/ - Download PDF -GET /v1/billing/payments/ - List payments -POST /v1/billing/payments/manual/ - Submit manual payment -``` - -### Admin Payment Management - -``` -GET /v1/admin/billing/pending_payments/ - List pending approvals -POST /v1/admin/billing/{id}/approve_payment/ - Approve payment -POST /v1/admin/billing/{id}/reject_payment/ - Reject payment -``` - -### Payment Methods - -``` -GET /v1/billing/payment-methods/ - Get user's payment methods -POST /v1/billing/payment-methods/ - Create payment method -POST /v1/billing/payment-methods/{id}/set_default/ - Set default -GET /v1/billing/payment-configs/payment-methods/ - Get available configs -``` diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md deleted file mode 100644 index ff2321e9..00000000 --- a/docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md +++ /dev/null @@ -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 - - {hasActivePlan ? 'Active' : 'Inactive'} - -``` - -**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; - - - {isFullyActive ? 'Active' : accountStatus === 'pending_payment' ? 'Pending Payment' : 'Inactive'} - -``` - ---- - -### 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 - -``` - -**Problem:** User with pending invoice can click "Upgrade" and attempt to subscribe to another plan, creating confusion and potentially duplicate subscriptions. - -**Fix Required:** -```tsx - -``` - ---- - -### HIGH UX Issue #4: Cancel Subscription Available When Account Already Pending - -**Location:** `PlansAndBillingPage.tsx:609-616` - -**Current Code:** -```tsx -{hasActivePlan && ( - -)} -``` - -**Problem:** User with `pending_payment` status can "cancel" a subscription they never paid for. This is confusing. - -**Fix Required:** -```tsx -{hasActivePlan && accountStatus === 'active' && !hasPendingInvoice && ( - -)} -``` - ---- - -### HIGH UX Issue #5: "Manage Billing" Button Shown to Non-Stripe Users - -**Location:** `PlansAndBillingPage.tsx:468-477` - -**Current Code:** -```tsx -{availableGateways.stripe && hasActivePlan && ( - -)} -``` - -**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' && ( - -)} -``` - ---- - -### 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 && ( -
-

- You have {pendingCreditPayments.length} credit purchase(s) pending approval -

-
-)} -``` - ---- - -### MEDIUM UX Issue #7: Invoice Status Badge Colors Inconsistent - -**Location:** `PlansAndBillingPage.tsx:817-819` - -**Current Code:** -```tsx - - {invoice.status} - -``` - -**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 - - {invoice.payment_method === 'bank_transfer' ? ( - Bank - ) : invoice.payment_method === 'paypal' ? ( - PayPal - ) : ( - Card - )} - -``` - ---- - -### MEDIUM UX Issue #9: Renewal Date Shows Even When No Active Subscription - -**Location:** `PlansAndBillingPage.tsx:514-522` - -**Current Code:** -```tsx -
- {currentSubscription?.current_period_end - ? new Date(currentSubscription.current_period_end).toLocaleDateString(...) - : 'β€”'} -
-
Next billing
-``` - -**Problem:** Shows "β€”" and "Next billing" even when: -- Account is `pending_payment` (never billed yet) -- Subscription is `canceled` (won't renew) - -**Fix Required:** -```tsx -
- {currentSubscription?.status === 'canceled' ? 'Ends on' : - currentSubscription?.status === 'pending_payment' ? 'Starts after payment' : - 'Next billing'} -
-``` - ---- - -### 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 -

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.* diff --git a/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md b/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md deleted file mode 100644 index cbda4229..00000000 --- a/docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md +++ /dev/null @@ -1,2365 +0,0 @@ -# Payment System Complete Refactor Plan - -> **Version:** 1.0 -> **Date:** January 7, 2026 -> **Status:** In Progress - Phase 1 Complete βœ… - ---- - -## Executive Summary - -This plan consolidates all audit findings and architectural discussions into a comprehensive refactor that will: - -1. **Simplify signup flow** - Remove payment gateway redirect from signup -2. **Unify payment experience** - Single payment interface in `/account/plans` -3. **Fix all critical security issues** - Webhook verification, idempotency -4. **Clean up dead code** - Remove `/pk` variant, country detection API, unused fields -5. **Implement proper state handling** - Account lifecycle properly reflected in UI - ---- - -## Architecture Changes - -### Before (Current) - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ CURRENT FLOW β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β”‚ -β”‚ /signup OR /signup/pk β”‚ -β”‚ β”‚ β”‚ -β”‚ β”œβ”€β”€ Country detection API call β”‚ -β”‚ β”œβ”€β”€ Load payment methods API call β”‚ -β”‚ β”œβ”€β”€ Show payment method selection β”‚ -β”‚ β”‚ β”‚ -β”‚ └── On Submit: β”‚ -β”‚ β”œβ”€β”€ Stripe β†’ Redirect to Stripe β†’ Return to /plans β”‚ -β”‚ β”œβ”€β”€ PayPal β†’ Redirect to PayPal β†’ Return to /plans β”‚ -β”‚ └── Bank β†’ Navigate to /plans (show banner) β”‚ -β”‚ β”‚ -β”‚ PROBLEMS: β”‚ -β”‚ - Two signup routes (/signup and /signup/pk) β”‚ -β”‚ - Country detection API complexity β”‚ -β”‚ - Payment gateway redirect from signup β”‚ -β”‚ - User loses context if redirect fails β”‚ -β”‚ - Duplicate payment logic (signup + plans page) β”‚ -β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### After (New) - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ NEW FLOW β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β”‚ -β”‚ /signup (SINGLE ROUTE) β”‚ -β”‚ β”‚ β”‚ -β”‚ β”œβ”€β”€ Country dropdown (auto-detect default using IP API) β”‚ -β”‚ β”œβ”€β”€ Plan selection β”‚ -β”‚ β”œβ”€β”€ NO payment method selection β”‚ -β”‚ β”‚ β”‚ -β”‚ └── On Submit: β”‚ -β”‚ └── Create account (pending_payment) β”‚ -β”‚ └── Navigate to /account/plans β”‚ -β”‚ β”‚ -β”‚ /account/plans β”‚ -β”‚ β”‚ β”‚ -β”‚ β”œβ”€β”€ IF new user (never paid): β”‚ -β”‚ β”‚ └── Show PendingPaymentView (full page) β”‚ -β”‚ β”‚ - Invoice details β”‚ -β”‚ β”‚ - Payment method selection (based on country) β”‚ -β”‚ β”‚ - Inline payment form β”‚ -β”‚ β”‚ β”‚ -β”‚ β”œβ”€β”€ IF existing user (has paid before): β”‚ -β”‚ β”‚ └── Show ActiveSubscriptionView β”‚ -β”‚ β”‚ - Current plan details β”‚ -β”‚ β”‚ - Credit balance β”‚ -β”‚ β”‚ - Buy credits (modal) β”‚ -β”‚ β”‚ - Billing history β”‚ -β”‚ β”‚ - IF pending invoice: Show banner + Pay modal β”‚ -β”‚ β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - ---- - -## Phase 1: Backend Cleanup & Security Fixes βœ… - -### 1.1 Fix Critical Security Issues βœ… - -#### 1.1.1 Enable PayPal Webhook Signature Verification βœ… - -**File:** `backend/igny8_core/business/billing/views/paypal_views.py` - -```python -# Line ~510 - UNCOMMENT the rejection -if not is_valid: - logger.error("PayPal webhook signature verification failed") - return Response({'error': 'Invalid signature'}, status=400) # UNCOMMENT THIS -``` - -#### 1.1.2 Add Stripe Webhook Idempotency βœ… - -**File:** `backend/igny8_core/business/billing/views/stripe_views.py` - -```python -# Add at start of _handle_checkout_completed (around line 380): -@transaction.atomic -def _handle_checkout_completed(session): - session_id = session.get('id') - - # IDEMPOTENCY CHECK - if Payment.objects.filter( - metadata__stripe_checkout_session_id=session_id - ).exists(): - logger.info(f"Webhook already processed for session {session_id}") - return - - # ... rest of handler -``` - -#### 1.1.3 Add PayPal Capture Idempotency βœ… - -**File:** `backend/igny8_core/business/billing/views/paypal_views.py` - -```python -# Add at start of PayPalCaptureOrderView.post (around line 270): -def post(self, request): - order_id = request.data.get('order_id') - - # IDEMPOTENCY CHECK - existing = Payment.objects.filter( - paypal_order_id=order_id, - status='succeeded' - ).first() - if existing: - return Response({ - 'status': 'already_captured', - 'payment_id': existing.id, - 'message': 'This order has already been captured' - }) - - # ... rest of handler -``` - -#### 1.1.4 Add PayPal Amount Validation βœ… - -**File:** `backend/igny8_core/business/billing/views/paypal_views.py` - -```python -# In _process_credit_purchase (around line 570): -captured_amount = Decimal(str(capture_result.get('amount', '0'))) -expected_amount = Decimal(str(package.price)) - -if abs(captured_amount - expected_amount) > Decimal('0.01'): - logger.error(f"Amount mismatch: captured={captured_amount}, expected={expected_amount}") - return Response({ - 'error': 'Payment amount does not match expected amount', - 'captured': str(captured_amount), - 'expected': str(expected_amount) - }, status=400) -``` - -#### 1.1.5 Fix Refund Module Imports βœ… - -**File:** `backend/igny8_core/business/billing/views/refund_views.py` - -```python -# Replace non-existent imports (around line 160): -# FROM: -from igny8_core.business.billing.utils.payment_gateways import get_stripe_client -from igny8_core.business.billing.utils.payment_gateways import get_paypal_client - -# TO: -from igny8_core.business.billing.services.stripe_service import StripeService -from igny8_core.business.billing.services.paypal_service import PayPalService - -# Then use: -stripe_service = StripeService() -stripe_service.create_refund(payment_intent_id, amount) -``` - ---- - -### 1.2 Simplify Registration Serializer βœ… - -**File:** `backend/igny8_core/auth/serializers.py` - -**REMOVE from RegisterSerializer.create():** -- Stripe checkout session creation -- PayPal order creation -- `checkout_url` return -- `checkout_session_id` return -- `paypal_order_id` return - -**KEEP:** -- User creation -- Account creation with `status='pending_payment'` -- Subscription creation with `status='pending_payment'` -- Invoice creation -- AccountPaymentMethod creation (just store type, don't verify) - -**NEW create() method (simplified):** - -```python -def create(self, validated_data): - # Extract fields - plan_slug = validated_data.pop('plan_slug', 'free') - billing_country = validated_data.pop('billing_country', '') - - # Create user - user = User.objects.create_user(...) - - # Get plan - plan = Plan.objects.get(slug=plan_slug, is_active=True) - is_paid_plan = plan.price > 0 - - # Create account - account = Account.objects.create( - name=validated_data.get('account_name', user.username), - owner=user, - plan=plan, - credits=0 if is_paid_plan else plan.included_credits, - status='pending_payment' if is_paid_plan else 'active', - billing_country=billing_country, - ) - - if is_paid_plan: - # Create subscription - subscription = Subscription.objects.create( - account=account, - plan=plan, - status='pending_payment', - current_period_start=timezone.now(), - current_period_end=timezone.now() + timedelta(days=30), - ) - - # Create invoice - InvoiceService.create_subscription_invoice( - subscription=subscription, - payment_method=None, # Will be set when user pays - ) - - return user # NO checkout_url returned -``` - -**File:** `backend/igny8_core/auth/views.py` - -**REMOVE from RegisterView.post():** -- Stripe checkout session creation -- PayPal order creation -- `checkout_url` in response - -**NEW response (simplified):** - -```python -def post(self, request): - serializer = RegisterSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.save() - - # Generate tokens - tokens = get_tokens_for_user(user) - - return Response({ - 'success': True, - 'user': UserSerializer(user).data, - 'tokens': tokens, - # NO checkout_url - user will pay on /account/plans - }) -``` - ---- - -### 1.3 Add Country List Endpoint βœ… - -**File:** `backend/igny8_core/auth/urls.py` (NEW) - -```python -class CountryListView(APIView): - """Returns list of countries for signup dropdown""" - permission_classes = [] # Public endpoint - - def get(self, request): - countries = [ - {'code': 'US', 'name': 'United States'}, - {'code': 'GB', 'name': 'United Kingdom'}, - {'code': 'CA', 'name': 'Canada'}, - {'code': 'AU', 'name': 'Australia'}, - {'code': 'PK', 'name': 'Pakistan'}, - {'code': 'IN', 'name': 'India'}, - # ... full list - ] - return Response({'countries': countries}) -``` - -**File:** `backend/igny8_core/auth/urls.py` - -```python -path('countries/', CountryListView.as_view(), name='country-list'), -``` - ---- - -### 1.4 Add WebhookEvent Model for Audit Trail βœ… - -**File:** `backend/igny8_core/business/billing/models.py` (ADD) - -```python -class WebhookEvent(models.Model): - """Store all incoming webhook events for audit and replay""" - event_id = models.CharField(max_length=255, unique=True) - provider = models.CharField(max_length=20) # 'stripe' or 'paypal' - event_type = models.CharField(max_length=100) - payload = models.JSONField() - processed = models.BooleanField(default=False) - processed_at = models.DateTimeField(null=True, blank=True) - error_message = models.TextField(blank=True) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - indexes = [ - models.Index(fields=['provider', 'event_type']), - models.Index(fields=['processed']), - ] -``` - ---- - -### 1.5 Add Invoice Unique Constraint βœ… - -**Migration:** Add unique constraint to invoice_number - -```python -# In Invoice model -invoice_number = models.CharField(max_length=50, unique=True) # Already has unique=True -``` - -**Update invoice_service.py** to handle IntegrityError: - -```python -from django.db import IntegrityError - -@transaction.atomic -def generate_invoice_number(account_id, invoice_type='SUB'): - prefix = f"INV-{account_id}-{invoice_type}-{timezone.now().strftime('%Y%m')}" - - for attempt in range(5): - count = Invoice.objects.filter( - invoice_number__startswith=prefix - ).count() - invoice_number = f"{prefix}-{count + 1:04d}" - - # Check if exists (within transaction) - if not Invoice.objects.filter(invoice_number=invoice_number).exists(): - return invoice_number - - raise ValueError(f"Unable to generate unique invoice number after 5 attempts") -``` - ---- - -### 1.6 Add Manual Reference Uniqueness βœ… - -**Migration:** - -```python -class Migration(migrations.Migration): - operations = [ - migrations.AlterField( - model_name='payment', - name='manual_reference', - field=models.CharField( - max_length=255, - blank=True, - null=True, - unique=True, # ADD UNIQUE - ), - ), - ] -``` - ---- - -## Phase 2: Frontend Cleanup βœ… - -### 2.1 Simplify SignUpFormUnified.tsx βœ… - -**File:** `frontend/src/components/auth/SignUpFormUnified.tsx` - -**REMOVE:** βœ… -- Payment method selection UI -- Payment method loading from API (`/v1/billing/payment-configs/payment-methods/`) -- Stripe checkout redirect logic -- PayPal redirect logic -- `isPakistanSignup` variant logic -- All payment gateway-related state - -**KEEP:** βœ… -- Email, password, name fields -- Plan selection -- Country dropdown (NEW - replaces detection) - -**ADD:** βœ… -- Country dropdown with auto-detection default -- Simplified submit that only creates account - -**NEW component structure:** - -```tsx -export default function SignUpFormUnified() { - // State - const [formData, setFormData] = useState({ - email: '', - password: '', - username: '', - first_name: '', - last_name: '', - account_name: '', - billing_country: '', // From dropdown - }); - const [selectedPlan, setSelectedPlan] = useState(null); - const [countries, setCountries] = useState([]); - 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 ( -
- {/* Email, Password, Name fields */} - - {/* Country Dropdown - NEW */} -