# IMPLEMENTATION PLAN: Clean Signup to Payment Workflow **Version:** 2.0 **Date:** December 8, 2025 **Status:** 🔧 READY FOR IMPLEMENTATION --- ## Table of Contents 1. [Executive Summary](#executive-summary) 2. [Deep Analysis Findings](#deep-analysis-findings) 3. [Field & Relationship Audit](#field--relationship-audit) 4. [Simplified Model Architecture](#simplified-model-architecture) 5. [Complete Workflow Diagrams](#complete-workflow-diagrams) 6. [Implementation Phases](#implementation-phases) 7. [Database Migrations](#database-migrations) 8. [Testing Plan](#testing-plan) --- ## Executive Summary ### Current State Analysis **Critical Issues Found:** 1. **Duplicate Date Fields** - Period dates stored in 2 places (Subscription + Invoice) 2. **Payment Method Chaos** - Stored in 3 different models with no single source of truth 3. **Missing Relationships** - Subscription has no `plan` field, breaking invoice creation 4. **Unused Payment References** - No `external_payment_id` captured for manual payments 5. **Profile Fields Not Updated** - Billing info never synced from account to invoices 6. **Country-Specific Logic Missing** - Pakistan payment methods not filtered 7. **Site Industry Not Required** - Can create sites without industry, then can't add sectors 8. **Broken Paid Signup Flow** - Imports non-existent `billing.Subscription` 9. **Credits Not Single Source** - Account.credits, Plan.included_credits not synchronized 10. **Invoice PDF Missing** - No pdf_file field, no proper PDF generation/download 11. **No Billing Settings Section** - Collect minimal data in signup, need account settings for full billing info **Complexity Issues:** - Payment method filtering by region not properly implemented - Payment method config table exists but not used in signup flow - Manual payment instructions not shown to users - No clear workflow for payment confirmation after signup - Credits stored in multiple places instead of single source - Invoice PDF field missing, no download capability - Billing data collected but no account settings section for updates ### Target Architecture **Simplified Models:** ``` Account (1) ──────┐ ├─ plan_id │ ├─ credits │ └─ status │ │ Subscription (1) │◄── ONE-TO-ONE relationship ├─ account_id │ ├─ plan_id │◄── ADDED (currently missing) ├─ period_start │◄── SINGLE source of truth for dates ├─ period_end │ └─ status │ │ Invoice (many) │◄── Created from Subscription ├─ subscription_id ├─ total │◄── No duplicate period dates └─ status │ │ Payment (many) │◄── Links to Invoice ├─ invoice_id │ ├─ payment_method ├─ external_payment_id ◄── ADDED for manual payments └─ status │ ``` **Simplified Payment Methods:** - **Global (2):** Stripe, PayPal (available worldwide) - **Regional (2):** - Bank Transfer (UK, USA, Canada, Europe only) - Local Wallet (Pakistan only - JazzCash/Easypaisa) - **Total:** 4 payment methods maximum per region --- ## Deep Analysis Findings ### 1. Date Field Redundancy **Problem:** Period dates duplicated in multiple places **Current State:** ```python # Subscription model (auth/models.py line 253-254) current_period_start = models.DateTimeField() current_period_end = models.DateTimeField() # Invoice model (billing/models.py line 212-213) billing_period_start = models.DateTimeField(null=True, blank=True) billing_period_end = models.DateTimeField(null=True, blank=True) ``` **Issues:** - Same period stored in 2 places - Can become out of sync - Invoice fields are nullable but subscription fields are required - No automatic sync mechanism **Solution:** ✅ Keep dates in `Subscription` only (single source of truth) ✅ Remove `billing_period_start/end` from Invoice ✅ Access dates via `invoice.subscription.current_period_start` **Migration Required:** ```python # 1. Remove fields from Invoice model migrations.RemoveField('invoice', 'billing_period_start') migrations.RemoveField('invoice', 'billing_period_end') # No data loss - dates preserved in Subscription ``` --- ### 2. Payment Method Field Chaos **Problem:** Payment method stored in 3 different models with no clear hierarchy **Current Locations:** ```python # 1. Account (auth/models.py line 87-92) payment_method = models.CharField( max_length=30, choices=PAYMENT_METHOD_CHOICES, default='stripe' ) # 2. Subscription (auth/models.py line 240-244) payment_method = models.CharField( max_length=30, choices=PAYMENT_METHOD_CHOICES, default='stripe' ) # 3. Payment (billing/models.py line 309) payment_method = models.CharField( max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True ) # 4. AccountPaymentMethod (billing/models.py line 476) ✓ CORRECT type = models.CharField( max_length=50, choices=PAYMENT_METHOD_CHOICES ) ``` **Which is Used?** - Registration: Sets `Account.payment_method` and creates `AccountPaymentMethod` - Subscription creation: Copies from account or uses 'bank_transfer' default - Payment processing: Uses `Payment.payment_method` - **Result:** 3 fields that can be out of sync **Solution:** ✅ Use `AccountPaymentMethod` as single source of truth ✅ Deprecate `Account.payment_method` (make it read-only derived property) ✅ Deprecate `Subscription.payment_method` (get from account's default method) ✅ Keep `Payment.payment_method` for historical record **New Pattern:** ```python # Account model @property def payment_method(self): """Get default payment method from AccountPaymentMethod""" default_method = self.accountpaymentmethod_set.filter( is_default=True, is_enabled=True ).first() return default_method.type if default_method else 'stripe' # Subscription model - remove field, use property @property def payment_method(self): return self.account.payment_method ``` --- ### 3. Missing Subscription.plan Relationship **Problem:** Subscription has no plan field, breaking invoice logic **Current State:** ```python # Subscription model (auth/models.py line 218-260) class Subscription(models.Model): account = models.OneToOneField('Account') # ❌ NO PLAN FIELD stripe_subscription_id = CharField payment_method = CharField status = CharField current_period_start = DateTimeField current_period_end = DateTimeField ``` **Code Expects Plan:** ```python # InvoiceService.create_subscription_invoice() subscription.plan # ❌ AttributeError: 'Subscription' object has no attribute 'plan' ``` **Why It's Missing:** - Plan stored in `Account.plan` only - Assumption: Get plan via `subscription.account.plan` - **Problem:** If account changes plan mid-subscription, historical invoices show wrong plan **Solution:** ✅ Add `Subscription.plan` field ✅ Set from `Account.plan` at subscription creation ✅ Keep plan locked for subscription duration (historical accuracy) ✅ New subscription created when plan changes **Migration:** ```python migrations.AddField( model_name='subscription', name='plan', field=models.ForeignKey( 'igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='subscriptions', null=True # Temporarily nullable ), ) # Data migration: Copy from account def copy_plan_from_account(apps, schema_editor): Subscription = apps.get_model('igny8_core_auth', 'Subscription') for sub in Subscription.objects.all(): sub.plan = sub.account.plan sub.save() # Make required migrations.AlterField( model_name='subscription', name='plan', field=models.ForeignKey( 'igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='subscriptions' ), ) ``` --- ### 4. Missing Payment References for Manual Payments **Problem:** No way to track bank transfer references or local wallet transaction IDs **Current State:** ```python # Payment model has these fields: stripe_payment_intent_id = CharField # ✓ For Stripe stripe_charge_id = CharField # ✓ For Stripe paypal_order_id = CharField # ✓ For PayPal paypal_capture_id = CharField # ✓ For PayPal # For manual payments: manual_reference = CharField(blank=True) # ✓ EXISTS but not used transaction_reference = CharField(blank=True) # ⚠️ DUPLICATE field ``` **Issues:** - Two fields for same purpose (`manual_reference` and `transaction_reference`) - Neither field is populated during signup - No validation requiring reference for manual payments - Admin approval has no reference number to verify **Solution:** ✅ Keep `manual_reference` only (remove `transaction_reference`) ✅ Require `manual_reference` for bank_transfer and local_wallet payments ✅ Show reference field in payment confirmation form ✅ Display reference in admin approval interface **Validation:** ```python class Payment(models.Model): def clean(self): # Require manual_reference for manual payment methods if self.payment_method in ['bank_transfer', 'local_wallet']: if not self.manual_reference: raise ValidationError({ 'manual_reference': 'Reference number required for manual payments' }) ``` --- ### 5. Billing Profile Fields Not Updated **Problem:** Account billing fields exist but never populated or synced **Current Account Fields:** ```python # Account model (auth/models.py line 100-110) billing_email = models.EmailField(blank=True, null=True) billing_address_line1 = CharField(max_length=255, blank=True) billing_address_line2 = CharField(max_length=255, blank=True) billing_city = CharField(max_length=100, blank=True) billing_state = CharField(max_length=100, blank=True) billing_postal_code = CharField(max_length=20, blank=True) billing_country = CharField(max_length=2, blank=True) tax_id = CharField(max_length=100, blank=True) ``` **When Are They Set?** ❌ Not set during registration ❌ Not set during payment ❌ No form to update them ❌ Invoice has duplicate `billing_email` field **Solution:** ✅ Add billing form during paid plan signup (before payment) ✅ Update account billing fields when user confirms payment ✅ Snapshot billing info to `Invoice.metadata` at invoice creation ✅ Remove duplicate `Invoice.billing_email` field **Signup Flow Update:** ``` 1. User selects paid plan 2. User fills registration form 3. User fills billing info form ← NEW STEP 4. User selects payment method 5. Payment instructions shown 6. Account created with billing info 7. Invoice created with billing snapshot ``` --- ### 6. Payment Method Country Logic Missing **Problem:** Pakistan-specific payment method not filtered by country **Current Setup:** ```python # PaymentMethodConfig model exists (billing/models.py line 410) class PaymentMethodConfig(models.Model): country_code = CharField # e.g., 'PK' payment_method = CharField # e.g., 'local_wallet' is_enabled = BooleanField instructions = TextField wallet_type = CharField # e.g., 'JazzCash', 'Easypaisa' ``` **Issues:** - Config exists but NOT used in signup flow - Frontend shows all 4 payment methods to everyone - No country detection - No filtering logic **Solution:** ✅ Detect user country from IP or billing_country ✅ Query `PaymentMethodConfig` for available methods ✅ Show only enabled methods for user's country ✅ Create default configs for global methods **Default Configurations:** ```python # Global methods (available everywhere) PaymentMethodConfig.objects.create( country_code='*', # Wildcard for all countries payment_method='stripe', is_enabled=True, display_name='Credit/Debit Card (Stripe)', sort_order=1 ) PaymentMethodConfig.objects.create( country_code='*', payment_method='paypal', is_enabled=True, display_name='PayPal', sort_order=2 ) PaymentMethodConfig.objects.create( country_code='*', payment_method='bank_transfer', is_enabled=True, display_name='Bank Transfer', instructions='Transfer to: Account 123456...', sort_order=3 ) # Pakistan-specific PaymentMethodConfig.objects.create( country_code='PK', payment_method='local_wallet', is_enabled=True, display_name='JazzCash / Easypaisa', wallet_type='JazzCash', wallet_id='03001234567', instructions='Send payment to JazzCash: 03001234567', sort_order=4 ) ``` **API Endpoint:** ```python # New endpoint: GET /v1/billing/payment-methods/?country=PK def list_payment_methods(request): country = request.GET.get('country', '*') # Get country-specific + global methods methods = PaymentMethodConfig.objects.filter( Q(country_code=country) | Q(country_code='*'), is_enabled=True ).order_by('sort_order') return Response(PaymentMethodConfigSerializer(methods, many=True).data) ``` --- ### 7. Site.industry Field Not Required **Problem:** Industry is nullable but required for sector creation **Current State:** ```python # Site model (auth/models.py line 280-286) industry = models.ForeignKey( 'Industry', on_delete=models.PROTECT, related_name='sites', null=True, # ❌ NULLABLE blank=True, help_text="Industry this site belongs to" ) ``` **Serializer:** ```python # SiteSerializer (auth/serializers.py line 60-82) class Meta: fields = ['industry', ...] # ❌ industry not in required_fields ``` **Impact:** - Sites created without industry - Sector creation fails: `if self.industry_sector.industry != self.site.industry` - If site.industry is None, comparison always fails - User can't add any sectors **Solution:** ✅ Make `Site.industry` required (not nullable) ✅ Update migration to set default industry for existing NULL sites ✅ Update serializer to require industry ✅ Update frontend to show industry selector in site creation form **Migration:** ```python # 1. Set default industry for existing sites def set_default_industry(apps, schema_editor): Site = apps.get_model('igny8_core_auth', 'Site') Industry = apps.get_model('igny8_core_auth', 'Industry') default_industry = Industry.objects.filter( slug='technology' ).first() if default_industry: Site.objects.filter(industry__isnull=True).update( industry=default_industry ) migrations.RunPython(set_default_industry) # 2. Make field required migrations.AlterField( model_name='site', name='industry', field=models.ForeignKey( 'Industry', on_delete=models.PROTECT, related_name='sites' # Removed: null=True, blank=True ), ) ``` --- ### 8. Broken Paid Signup Import **Problem:** Imports non-existent `billing.Subscription` **Current Code:** ```python # auth/serializers.py line 291 from igny8_core.business.billing.models import Subscription # ❌ DOES NOT EXIST ``` **Fix:** ```python # Use existing auth.Subscription from igny8_core.auth.models import Subscription # ✓ EXISTS ``` --- ## Field & Relationship Audit ### Complete Field Inventory #### Subscription Model - BEFORE Cleanup ```python class Subscription(models.Model): # Relationships account = OneToOneField('Account') # ✓ KEEP # ❌ MISSING: plan field # Payment tracking stripe_subscription_id = CharField # ✓ KEEP payment_method = CharField # ❌ REMOVE (use AccountPaymentMethod) external_payment_id = CharField # ✓ KEEP # Status & dates status = CharField # ✓ KEEP current_period_start = DateTimeField # ✓ KEEP (source of truth) current_period_end = DateTimeField # ✓ KEEP (source of truth) cancel_at_period_end = BooleanField # ✓ KEEP # Audit created_at = DateTimeField # ✓ KEEP updated_at = DateTimeField # ✓ KEEP ``` #### Subscription Model - AFTER Cleanup ```python class Subscription(models.Model): # Relationships account = OneToOneField('Account') # ✓ Tenant isolation plan = ForeignKey('Plan') # ✅ ADDED - historical plan tracking # Payment tracking stripe_subscription_id = CharField # ✓ Stripe reference external_payment_id = CharField # ✓ Manual payment reference # Status & dates (SINGLE SOURCE OF TRUTH) status = CharField # ✓ active/past_due/canceled current_period_start = DateTimeField # ✓ Billing cycle start current_period_end = DateTimeField # ✓ Billing cycle end cancel_at_period_end = BooleanField # ✓ Cancellation flag # Audit created_at = DateTimeField updated_at = DateTimeField # Properties (derived, not stored) @property def payment_method(self): """Get from AccountPaymentMethod""" return self.account.payment_method ``` **Changes Summary:** - ✅ Added: `plan` field (FK to Plan) - ❌ Removed: `payment_method` field (use property instead) - Field count: 11 → 11 (same, but cleaner relationships) --- #### Invoice Model - BEFORE Cleanup ```python class Invoice(AccountBaseModel): # Relationships account = ForeignKey('Account') # ✓ KEEP (via AccountBaseModel) subscription = ForeignKey('Subscription') # ✓ KEEP # Amounts subtotal = DecimalField # ✓ KEEP tax = DecimalField # ✓ KEEP total = DecimalField # ✓ KEEP currency = CharField # ✓ KEEP # Status & dates status = CharField # ✓ KEEP invoice_number = CharField # ✓ KEEP invoice_date = DateField # ✓ KEEP due_date = DateField # ✓ KEEP paid_at = DateTimeField # ✓ KEEP # DUPLICATE FIELDS billing_period_start = DateTimeField # ❌ REMOVE (in Subscription) billing_period_end = DateTimeField # ❌ REMOVE (in Subscription) billing_email = EmailField # ❌ REMOVE (use metadata snapshot) payment_method = CharField # ✓ KEEP (historical record) # Data line_items = JSONField # ✓ KEEP notes = TextField # ✓ KEEP metadata = JSONField # ✓ KEEP (billing snapshot goes here) # Stripe stripe_invoice_id = CharField # ✓ KEEP # Audit created_at = DateTimeField # ✓ KEEP updated_at = DateTimeField # ✓ KEEP ``` #### Invoice Model - AFTER Cleanup ```python class Invoice(AccountBaseModel): # Relationships account = ForeignKey('Account') # ✓ Tenant isolation subscription = ForeignKey('Subscription') # ✓ Links to subscription # Invoice identity invoice_number = CharField(unique=True) # ✓ INV-2025-001 # Amounts subtotal = DecimalField # ✓ Pre-tax amount tax = DecimalField # ✓ Tax amount total = DecimalField # ✓ Total payable currency = CharField(default='USD') # ✓ Currency code # Status & dates status = CharField # ✓ draft/pending/paid/void invoice_date = DateField # ✓ Issue date due_date = DateField # ✓ Payment deadline paid_at = DateTimeField(null=True) # ✓ Payment timestamp # Historical tracking payment_method = CharField # ✓ Payment method used # Data line_items = JSONField(default=list) # ✓ Invoice items notes = TextField(blank=True) # ✓ Admin notes metadata = JSONField(default=dict) # ✓ Billing snapshot # Stripe integration stripe_invoice_id = CharField # ✓ Stripe reference # Audit created_at = DateTimeField updated_at = DateTimeField # Properties (access via relationship) @property def billing_period_start(self): """Get from subscription""" return self.subscription.current_period_start if self.subscription else None @property def billing_period_end(self): """Get from subscription""" return self.subscription.current_period_end if self.subscription else None @property def billing_email(self): """Get from metadata snapshot or account""" return self.metadata.get('billing_email') or self.account.billing_email ``` **Changes Summary:** - ❌ Removed: `billing_period_start` (get from subscription) - ❌ Removed: `billing_period_end` (get from subscription) - ❌ Removed: `billing_email` (use metadata snapshot) - Field count: 21 → 18 (3 fields removed, cleaner) --- ## Simplified Model Architecture ### Core Models Summary | Model | Fields (Before) | Fields (After) | Changes | |-------|----------------|----------------|---------| | **Account** | 26 fields | 26 fields | Convert `payment_method` to property | | **Subscription** | 11 fields | 11 fields | ✅ Add `plan`, ❌ Remove `payment_method` field | | **Invoice** | 21 fields | 18 fields | ❌ Remove 3 duplicate fields | | **Payment** | 26 fields | 25 fields | ❌ Remove `transaction_reference` | | **AccountPaymentMethod** | 11 fields | 11 fields | No changes (already correct) | | **PaymentMethodConfig** | 14 fields | 14 fields | No changes (needs implementation) | **Total Field Reduction:** 109 fields → 105 fields (4 fewer fields, cleaner relationships) --- ### Relationship Map - BEFORE Cleanup ``` Account (1) ├─ plan_id ──────────────────► Plan ├─ payment_method (field) ⚠️ Can be out of sync │ ├─ Subscription (1:1) │ ├─ payment_method (field) ⚠️ Duplicate #2 │ ├─ period_start │ ├─ period_end │ └─ ❌ NO plan field │ ├─ Invoice (1:many) │ ├─ subscription_id │ ├─ period_start ⚠️ Duplicate dates │ ├─ period_end ⚠️ Duplicate dates │ ├─ billing_email ⚠️ Duplicate field │ └─ payment_method (field) ⚠️ Duplicate #3 │ ├─ Payment (1:many) │ ├─ invoice_id │ ├─ payment_method (field) ⚠️ Duplicate #4 │ ├─ manual_reference │ └─ transaction_reference ⚠️ Duplicate field │ └─ AccountPaymentMethod (1:many) ✓ Correct design ├─ type ├─ is_default └─ is_enabled ``` **Problems:** - 🔴 4 payment_method fields (which is source of truth?) - 🔴 Period dates duplicated (Subscription + Invoice) - 🔴 Billing email duplicated (Account + Invoice) - 🔴 Payment references duplicated (manual_reference + transaction_reference) - 🔴 No plan in Subscription (can't track plan changes) --- ### Relationship Map - AFTER Cleanup ``` Account (1) ├─ plan_id ──────────────────► Plan │ @property payment_method → get from AccountPaymentMethod │ ├─ Subscription (1:1) │ ├─ ✅ plan_id ──────────► Plan (added) │ ├─ period_start ◄─── SINGLE SOURCE │ ├─ period_end ◄─── SINGLE SOURCE │ └─ @property payment_method → get from Account │ ├─ Invoice (1:many) │ ├─ subscription_id ─────► Subscription │ ├─ @property period_start → via subscription │ ├─ @property period_end → via subscription │ ├─ @property billing_email → via metadata │ └─ payment_method (field) ✓ Historical record │ ├─ Payment (1:many) │ ├─ invoice_id ──────────► Invoice │ ├─ payment_method (field) ✓ Historical record │ └─ manual_reference ✓ Single field │ └─ AccountPaymentMethod (1:many) ◄─── PRIMARY SOURCE ├─ type ◄─── Source of truth ├─ is_default ├─ is_enabled └─ instructions PaymentMethodConfig (global) ├─ country_code ├─ payment_method ├─ is_enabled └─ instructions ``` **Solutions:** - ✅ AccountPaymentMethod is single source of truth - ✅ Subscription has plan_id for historical tracking - ✅ Period dates only in Subscription (Invoice uses properties) - ✅ Billing email only in Account (Invoice snapshots to metadata) - ✅ Single payment reference field - ✅ Clear hierarchy: Config → AccountPaymentMethod → Historical records --- ## Complete Workflow Diagrams ### 1. FREE TRIAL SIGNUP FLOW ``` ┌────────────────────────────────────────────────────────────────┐ │ USER: Clicks "Start Free Trial" on homepage │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: /signup (no plan parameter) │ │ │ │ Form Fields: │ │ ├─ First Name* │ │ ├─ Last Name* │ │ ├─ Email* │ │ ├─ Password* │ │ ├─ Account Name (optional) │ │ └─ ☑ Agree to Terms │ │ │ │ [Create Account] button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ API: POST /v1/auth/register/ │ │ │ │ Request Body: │ │ { │ │ "email": "user@example.com", │ │ "password": "SecurePass123!", │ │ "first_name": "John", │ │ "last_name": "Doe", │ │ "account_name": "John's Business" │ │ // plan_slug not provided = defaults to 'free' │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ BACKEND: RegisterSerializer.create() │ │ │ │ 1. Resolve Plan │ │ plan = Plan.objects.get(slug='free') │ │ status = 'trial' │ │ credits = 1000 (plan.included_credits) │ │ │ │ 2. Generate Username │ │ username = email.split('@')[0] │ │ (ensure unique with counter if needed) │ │ │ │ 3. Create User (without account first) │ │ user = User.objects.create_user( │ │ username=username, │ │ email=email, │ │ password=hashed_password, │ │ role='owner', │ │ account=None ← Will be set later │ │ ) │ │ │ │ 4. Generate Account Slug │ │ slug = slugify(account_name) │ │ (ensure unique with counter) │ │ │ │ 5. Create Account │ │ account = Account.objects.create( │ │ name=account_name, │ │ slug=slug, │ │ owner=user, │ │ plan=plan, │ │ credits=1000, │ │ status='trial' │ │ ) │ │ │ │ 6. Link User to Account │ │ user.account = account │ │ user.save() │ │ │ │ 7. Log Credit Transaction │ │ CreditTransaction.objects.create( │ │ account=account, │ │ transaction_type='subscription', │ │ amount=1000, │ │ balance_after=1000, │ │ description='Free plan credits' │ │ ) │ │ │ │ 8. Return User Object │ │ return user │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: Check account status │ │ │ │ if (user.account.status === 'trial') { │ │ navigate('/sites') ← Go to dashboard │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ USER: Lands on /sites (Sites page) │ │ │ │ - Can create 1 site (plan.max_sites = 1) │ │ - Has 1000 credits to use │ │ - Status: Free Trial │ └────────────────────────────────────────────────────────────────┘ ``` **Database State After Free Trial Signup:** ```sql -- igny8_tenants (Accounts) INSERT INTO igny8_tenants VALUES ( 1, 'Johns Business', 'johns-business', 1, 1000, 'trial', NULL ); -- id, name, slug, plan_id, credits, status, owner_id -- igny8_users INSERT INTO igny8_users VALUES ( 1, 'john@example.com', 'john-doe', 'owner', 1 ); -- id, email, username, role, tenant_id -- igny8_credit_transactions INSERT INTO igny8_credit_transactions VALUES ( 1, 1, 'subscription', 1000, 1000, 'Free plan credits' ); -- id, tenant_id, type, amount, balance_after, description -- igny8_subscriptions: NO RECORD (free trial has no subscription) -- igny8_invoices: NO RECORD -- igny8_payments: NO RECORD ``` --- ### 2. PAID PLAN SIGNUP FLOW (With Manual Payment) ``` ┌────────────────────────────────────────────────────────────────┐ │ USER: Clicks "Get Started" on Starter plan pricing card │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: /signup?plan=starter │ │ │ │ Page loads with plan details shown: │ │ ┌──────────────────────────────────────┐ │ │ │ 📋 Starter Plan │ │ │ │ $29/month │ │ │ │ ✓ 5,000 credits │ │ │ │ ✓ 3 sites │ │ │ │ ✓ 3 users │ │ │ └──────────────────────────────────────┘ │ │ │ │ Form Fields: │ │ ├─ First Name* │ │ ├─ Last Name* │ │ ├─ Email* │ │ ├─ Password* │ │ ├─ Account Name* │ │ └─ ☑ Agree to Terms │ │ │ │ [Continue to Billing] button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: /signup/billing (New Step) │ │ │ │ Billing Information Form: │ │ ├─ Billing Email* │ │ ├─ Country* (dropdown with flag icons) │ │ ├─ Address Line 1 │ │ ├─ Address Line 2 │ │ ├─ City │ │ ├─ State/Province │ │ ├─ Postal Code │ │ └─ Tax ID (optional) │ │ │ │ [Continue to Payment] button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: Fetch Available Payment Methods │ │ │ │ API: GET /v1/billing/payment-methods/?country=PK │ │ │ │ Response: │ │ [ │ │ { │ │ "payment_method": "stripe", │ │ "display_name": "Credit/Debit Card", │ │ "sort_order": 1 │ │ }, │ │ { │ │ "payment_method": "paypal", │ │ "display_name": "PayPal", │ │ "sort_order": 2 │ │ }, │ │ { │ │ "payment_method": "bank_transfer", │ │ "display_name": "Bank Transfer", │ │ "instructions": "Transfer to: Account 123456789...", │ │ "sort_order": 3 │ │ }, │ │ { │ │ "payment_method": "local_wallet", │ │ "display_name": "JazzCash / Easypaisa", │ │ "wallet_type": "JazzCash", │ │ "wallet_id": "03001234567", │ │ "instructions": "Send to JazzCash: 03001234567", │ │ "sort_order": 4 │ │ } │ │ ] │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: /signup/payment │ │ │ │ Payment Method Selection: │ │ ○ Credit/Debit Card (Stripe) │ │ ○ PayPal │ │ ● Bank Transfer ← User selects │ │ ○ JazzCash / Easypaisa │ │ │ │ [Conditional: If Bank Transfer selected] │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 📋 Bank Transfer Instructions │ │ │ │ │ │ │ │ Account Name: IGNY8 Inc │ │ │ │ Account Number: 123456789 │ │ │ │ Bank Name: ABC Bank │ │ │ │ SWIFT: ABCPKKA │ │ │ │ │ │ │ │ Amount: $29.00 USD │ │ │ │ │ │ │ │ ⚠️ Important: │ │ │ │ - Make payment to above account │ │ │ │ - Keep transaction reference │ │ │ │ - You'll confirm payment in next step │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ [Create Account & Continue] button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ API: POST /v1/auth/register/ │ │ │ │ Request Body: │ │ { │ │ "email": "user@example.com", │ │ "password": "SecurePass123!", │ │ "first_name": "Ahmad", │ │ "last_name": "Khan", │ │ "account_name": "Ahmad Tech", │ │ "plan_slug": "starter", │ │ "payment_method": "bank_transfer", │ │ "billing_email": "billing@ahmad.com", │ │ "billing_country": "PK", │ │ "billing_address_line1": "123 Main St", │ │ "billing_city": "Karachi" │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ BACKEND: RegisterSerializer.create() - PAID PLAN PATH │ │ │ │ 1. Resolve Plan │ │ plan = Plan.objects.get(slug='starter') │ │ status = 'pending_payment' │ │ credits = 0 ← No credits until payment confirmed │ │ │ │ 2. Calculate Period Dates │ │ period_start = timezone.now() │ │ period_end = period_start + 30 days │ │ │ │ 3. Create User │ │ user = User.objects.create_user(...) │ │ │ │ 4. Create Account WITH Billing Info │ │ account = Account.objects.create( │ │ name="Ahmad Tech", │ │ slug="ahmad-tech", │ │ owner=user, │ │ plan=plan, │ │ credits=0, │ │ status='pending_payment', │ │ billing_email="billing@ahmad.com", │ │ billing_country="PK", │ │ billing_address_line1="123 Main St", │ │ billing_city="Karachi" │ │ ) │ │ │ │ 5. Link User to Account │ │ user.account = account │ │ user.save() │ │ │ │ 6. Create Subscription ✅ WITH PLAN │ │ subscription = Subscription.objects.create( │ │ account=account, │ │ plan=plan, ← ADDED FIELD │ │ status='pending_payment', │ │ current_period_start=period_start, │ │ current_period_end=period_end, │ │ cancel_at_period_end=False │ │ ) │ │ │ │ 7. Create Invoice │ │ invoice = InvoiceService.create_subscription_invoice( │ │ subscription=subscription, │ │ billing_period_start=period_start, │ │ billing_period_end=period_end │ │ ) │ │ # Invoice fields: │ │ # - invoice_number: "INV-2025-001" │ │ # - total: 29.00 │ │ # - status: "pending" │ │ # - metadata: { billing snapshot } │ │ │ │ 8. Create AccountPaymentMethod │ │ AccountPaymentMethod.objects.create( │ │ account=account, │ │ type='bank_transfer', │ │ display_name='Bank Transfer (Manual)', │ │ is_default=True, │ │ is_enabled=True, │ │ instructions='See invoice for details' │ │ ) │ │ │ │ 9. NO CREDIT TRANSACTION (not yet paid) │ │ │ │ 10. Return User Object │ │ return user │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: Check account status │ │ │ │ if (user.account.status === 'pending_payment') { │ │ navigate('/account/plans') ← Go to payment confirmation │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ USER: Lands on /account/plans (Account & Plans Page) │ │ │ │ Account Status Bar: │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ ⚠️ Payment Pending │ │ │ │ Your account is pending payment confirmation │ │ │ │ [Confirm Payment] button │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ Current Plan: Starter Plan ($29/month) │ │ Status: Pending Payment │ │ Credits: 0 / 5,000 │ │ │ │ Invoices Tab: │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Invoice #INV-2025-001 │ │ │ │ Date: Dec 8, 2025 │ │ │ │ Amount: $29.00 │ │ │ │ Status: Pending │ │ │ │ Payment Method: Bank Transfer │ │ │ │ [Confirm Payment] button │ │ │ └──────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────┘ ``` **Database State After Paid Signup (Before Payment Confirmation):** ```sql -- igny8_tenants INSERT INTO igny8_tenants VALUES ( 2, 'Ahmad Tech', 'ahmad-tech', 2, 0, 'pending_payment', 'billing@ahmad.com', 'PK', '123 Main St', NULL, 'Karachi', NULL, NULL, NULL ); -- id, name, slug, plan_id, credits, status, billing_email, billing_country, address... -- igny8_users INSERT INTO igny8_users VALUES ( 2, 'user@example.com', 'ahmad-khan', 'owner', 2 ); -- igny8_subscriptions ✅ WITH PLAN NOW INSERT INTO igny8_subscriptions VALUES ( 2, 2, 2, NULL, 'pending_payment', '2025-12-08', '2026-01-08', FALSE ); -- id, tenant_id, plan_id, stripe_id, status, period_start, period_end, cancel_at_end -- igny8_invoices INSERT INTO igny8_invoices VALUES ( 2, 2, 2, 'INV-2025-002', 29.00, 0, 29.00, 'USD', 'pending', '2025-12-08', '2025-12-15' ); -- id, tenant_id, subscription_id, invoice_number, subtotal, tax, total, currency, status, invoice_date, due_date -- igny8_account_payment_methods INSERT INTO igny8_account_payment_methods VALUES ( 2, 2, 'bank_transfer', 'Bank Transfer (Manual)', TRUE, TRUE, FALSE ); -- id, tenant_id, type, display_name, is_default, is_enabled, is_verified -- igny8_payments: NO RECORD YET -- igny8_credit_transactions: NO RECORD YET ``` --- ### 3. PAYMENT CONFIRMATION FLOW (Manual Payment) ``` ┌────────────────────────────────────────────────────────────────┐ │ USER: Clicks "Confirm Payment" button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: Payment Confirmation Modal/Page │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 💳 Confirm Payment │ │ │ │ │ │ │ │ Invoice: #INV-2025-002 │ │ │ │ Amount: $29.00 USD │ │ │ │ Payment Method: Bank Transfer │ │ │ │ │ │ │ │ Payment Reference Number:* │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │ │ [Enter transaction reference] │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Payment Date:* │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │ │ [Select date] │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Additional Notes: │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │ │ [Optional notes about payment] │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Upload Proof (Optional): │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │ │ [Upload receipt/screenshot] │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ ⚠️ Your payment will be reviewed by our team within 24hrs│ │ │ │ │ │ │ │ [Cancel] [Submit for Approval] │ │ │ └──────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ API: POST /v1/billing/payments/confirm/ │ │ │ │ Request Body: │ │ { │ │ "invoice_id": 2, │ │ "payment_method": "bank_transfer", │ │ "manual_reference": "BT-20251208-12345", │ │ "manual_notes": "Paid via ABC Bank on Dec 8", │ │ "amount": "29.00", │ │ "proof_url": "https://s3.../receipt.jpg" │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ BACKEND: Create Payment Record - Pending Approval │ │ │ │ Payment.objects.create( │ │ account=account, │ │ invoice=invoice, │ │ amount=29.00, │ │ currency='USD', │ │ status='pending_approval', ← Awaiting admin approval │ │ payment_method='bank_transfer', │ │ manual_reference='BT-20251208-12345', ← User provided │ │ manual_notes='Paid via ABC Bank on Dec 8', │ │ metadata={ │ │ 'proof_url': 'https://s3.../receipt.jpg' │ │ } │ │ ) │ │ │ │ # Invoice remains "pending" until payment approved │ │ # Account remains "pending_payment" until payment approved │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: Success Message │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ ✅ Payment Submitted │ │ │ │ │ │ │ │ Your payment confirmation has been submitted for review. │ │ │ │ We'll verify your payment and activate your account │ │ │ │ within 24 hours. │ │ │ │ │ │ │ │ Reference: BT-20251208-12345 │ │ │ │ Status: Pending Review │ │ │ │ │ │ │ │ You'll receive an email once approved. │ │ │ │ │ │ │ │ [Go to Dashboard] │ │ │ └──────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ USER: Dashboard shows "Payment under review" status │ │ │ │ - Can browse platform but can't create sites │ │ - Credits still 0 until approved │ │ - Status shows "Pending Approval" │ └────────────────────────────────────────────────────────────────┘ ``` **Database State After Payment Submission:** ```sql -- igny8_payments (NEW RECORD) INSERT INTO igny8_payments VALUES ( 1, 2, 2, 29.00, 'USD', 'pending_approval', 'bank_transfer', NULL, NULL, NULL, NULL, 'BT-20251208-12345', 'Paid via ABC Bank on Dec 8', NULL, NULL, NULL, NULL, NULL, NULL, '{"proof_url": "https://s3.../receipt.jpg"}' ); -- id, tenant_id, invoice_id, amount, currency, status, payment_method, -- stripe fields, paypal fields, -- manual_reference, manual_notes, -- admin_notes, approved_by, approved_at, processed_at, failed_at, refunded_at, -- metadata ``` --- ### 4. ADMIN APPROVAL FLOW ``` ┌────────────────────────────────────────────────────────────────┐ │ ADMIN: Receives notification of pending payment │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ADMIN PANEL: /admin/billing/payment/ │ │ │ │ Pending Payments List: │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Payment #1 │ │ │ │ Account: Ahmad Tech │ │ │ │ Invoice: INV-2025-002 │ │ │ │ Amount: $29.00 │ │ │ │ Method: Bank Transfer │ │ │ │ Reference: BT-20251208-12345 │ │ │ │ Notes: Paid via ABC Bank on Dec 8 │ │ │ │ Proof: [View Receipt] │ │ │ │ │ │ │ │ [Approve] [Reject] │ │ │ └──────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ADMIN: Clicks "Approve" button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ API: POST /v1/billing/payments/1/approve/ │ │ │ │ Request Body: │ │ { │ │ "admin_notes": "Verified payment in bank statement" │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ BACKEND: Approve Payment & Activate Account │ │ │ │ with transaction.atomic(): │ │ # 1. Update Payment │ │ payment.status = 'succeeded' │ │ payment.approved_by = admin_user │ │ payment.approved_at = timezone.now() │ │ payment.processed_at = timezone.now() │ │ payment.admin_notes = 'Verified payment...' │ │ payment.save() │ │ │ │ # 2. Update Invoice │ │ invoice.status = 'paid' │ │ invoice.paid_at = timezone.now() │ │ invoice.save() │ │ │ │ # 3. Update Subscription │ │ subscription.status = 'active' │ │ subscription.external_payment_id = payment.manual_reference │ │ subscription.save() │ │ │ │ # 4. Update Account │ │ account.status = 'active' │ │ account.save() │ │ │ │ # 5. Add Credits │ │ CreditService.add_credits( │ │ account=account, │ │ amount=subscription.plan.included_credits, # 5000 │ │ description=f'Starter plan credits - {invoice.invoice_number}',│ │ metadata={ │ │ 'subscription_id': subscription.id, │ │ 'invoice_id': invoice.id, │ │ 'payment_id': payment.id │ │ } │ │ ) │ │ # This creates: │ │ # - Updates account.credits = 5000 │ │ # - Creates CreditTransaction record │ │ │ │ # 6. Send Activation Email │ │ send_account_activated_email(account, subscription) │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ USER: Receives email "Account Activated" │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ USER: Logs in and sees activated account │ │ │ │ Account Status: ✅ Active │ │ Plan: Starter Plan │ │ Credits: 5,000 / 5,000 │ │ Sites: 0 / 3 │ │ │ │ [Create Your First Site] button │ └────────────────────────────────────────────────────────────────┘ ``` **Database State After Admin Approval:** ```sql -- igny8_tenants (UPDATED) UPDATE igny8_tenants SET status = 'active', credits = 5000 WHERE id = 2; -- igny8_subscriptions (UPDATED) UPDATE igny8_subscriptions SET status = 'active', external_payment_id = 'BT-20251208-12345' WHERE id = 2; -- igny8_invoices (UPDATED) UPDATE igny8_invoices SET status = 'paid', paid_at = '2025-12-08 15:30:00' WHERE id = 2; -- igny8_payments (UPDATED) UPDATE igny8_payments SET status = 'succeeded', approved_by = 1, approved_at = '2025-12-08 15:30:00', processed_at = '2025-12-08 15:30:00', admin_notes = 'Verified payment in bank statement' WHERE id = 1; -- igny8_credit_transactions (NEW RECORD) INSERT INTO igny8_credit_transactions VALUES ( 2, 2, 'subscription', 5000, 5000, 'Starter plan credits - INV-2025-002', '{"subscription_id": 2, "invoice_id": 2, "payment_id": 1}' ); ``` --- ### 5. SITE CREATION FLOW (After Account Activated) ``` ┌────────────────────────────────────────────────────────────────┐ │ USER: Clicks "Create Your First Site" button │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: /sites/create │ │ │ │ Site Creation Form: │ │ ├─ Site Name* │ │ │ [Enter site name] │ │ │ │ │ ├─ Domain/URL │ │ │ [https://example.com] │ │ │ │ │ ├─ Industry* ✅ REQUIRED FIELD │ │ │ [Select industry ▼] │ │ │ - Technology │ │ │ - Healthcare │ │ │ - Finance │ │ │ - E-commerce │ │ │ ... │ │ │ │ │ ├─ Description │ │ │ [Describe your site...] │ │ │ │ │ └─ Site Type │ │ ○ Marketing Site │ │ ○ Blog │ │ ○ E-commerce │ │ ○ Corporate │ │ │ │ [Cancel] [Create Site] │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ API: POST /v1/auth/sites/ │ │ │ │ Request Body: │ │ { │ │ "name": "Tech News Hub", │ │ "domain": "https://technewshub.com", │ │ "industry": 2, ← REQUIRED (Technology industry ID) │ │ "description": "Latest technology news and tutorials", │ │ "site_type": "blog" │ │ } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ BACKEND: SiteViewSet.create() │ │ │ │ 1. Validate Account Plan Limits │ │ max_sites = account.plan.max_sites # 3 for Starter │ │ current_sites = account.sites.filter(is_active=True).count()│ │ if current_sites >= max_sites: │ │ raise ValidationError("Site limit reached") │ │ │ │ 2. Validate Industry Required ✅ │ │ if not validated_data.get('industry'): │ │ raise ValidationError("Industry is required") │ │ │ │ 3. Generate Unique Slug │ │ slug = slugify("Tech News Hub") # "tech-news-hub" │ │ # Ensure unique per account │ │ │ │ 4. Normalize Domain │ │ domain = "https://technewshub.com" │ │ # Ensure https:// prefix │ │ │ │ 5. Create Site │ │ site = Site.objects.create( │ │ account=request.account, # Auto from middleware │ │ name="Tech News Hub", │ │ slug="tech-news-hub", │ │ domain="https://technewshub.com", │ │ industry_id=2, # Technology │ │ description="Latest technology news...", │ │ site_type="blog", │ │ hosting_type="igny8_sites", │ │ status="active", │ │ is_active=True │ │ ) │ │ │ │ 6. Create SiteUserAccess ✅ NEW STEP │ │ SiteUserAccess.objects.create( │ │ user=request.user, │ │ site=site, │ │ granted_by=request.user │ │ ) │ │ │ │ 7. Return Site Object │ │ return site │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ FRONTEND: Redirect to /sites/{site_id}/sectors │ │ │ │ Success message: "Site created! Now add sectors to organize │ │ your content." │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ USER: Add Sectors to Site │ │ │ │ Available Sectors for Technology Industry: │ │ □ Web Development │ │ □ AI & Machine Learning │ │ □ Cybersecurity │ │ □ Cloud Computing │ │ □ Mobile Development │ │ │ │ Select up to 5 sectors (Starter plan limit) │ │ [Add Selected Sectors] │ └────────────────────────────────────────────────────────────────┘ ``` **Database State After Site Creation:** ```sql -- igny8_sites (NEW RECORD) INSERT INTO igny8_sites VALUES ( 1, 2, 'Tech News Hub', 'tech-news-hub', 'https://technewshub.com', 'Latest technology news and tutorials', 2, TRUE, 'active', 'blog', 'igny8_sites' ); -- id, tenant_id, name, slug, domain, description, industry_id, is_active, status, site_type, hosting_type -- igny8_site_user_access (NEW RECORD) ✅ INSERT INTO igny8_site_user_access VALUES ( 1, 2, 1, 2, '2025-12-08 16:00:00' ); -- id, user_id, site_id, granted_by_id, granted_at ``` --- ## Implementation Phases ### PHASE 1: Critical Fixes (URGENT - 1 Day) **Priority:** 🔴 Blocking Production #### 1.1 Fix Subscription Import ```python # File: backend/igny8_core/auth/serializers.py # Line 291 # BEFORE: from igny8_core.business.billing.models import Subscription # ❌ # AFTER: from igny8_core.auth.models import Subscription # ✅ ``` #### 1.2 Add Subscription.plan Field ```bash cd backend python manage.py makemigrations --name add_subscription_plan_field # Migration file created: 0XXX_add_subscription_plan_field.py ``` ```python # Migration content: from django.db import migrations, models def copy_plan_from_account(apps, schema_editor): """Copy plan from account to subscription""" Subscription = apps.get_model('igny8_core_auth', 'Subscription') for sub in Subscription.objects.select_related('account__plan'): sub.plan = sub.account.plan sub.save(update_fields=['plan']) class Migration(migrations.Migration): dependencies = [ ('igny8_core_auth', '0XXX_previous_migration'), ] operations = [ # 1. Add field as nullable migrations.AddField( model_name='subscription', name='plan', field=models.ForeignKey( 'igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='subscriptions', null=True ), ), # 2. Copy data migrations.RunPython( copy_plan_from_account, reverse_code=migrations.RunPython.noop ), # 3. Make non-nullable migrations.AlterField( model_name='subscription', name='plan', field=models.ForeignKey( 'igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='subscriptions' ), ), ] ``` ```bash # Apply migration python manage.py migrate ``` #### 1.3 Make Site.industry Required ```bash python manage.py makemigrations --name make_site_industry_required ``` ```python def set_default_industry(apps, schema_editor): """Set default industry for sites without one""" Site = apps.get_model('igny8_core_auth', 'Site') Industry = apps.get_model('igny8_core_auth', 'Industry') default = Industry.objects.filter(slug='technology').first() if default: Site.objects.filter(industry__isnull=True).update(industry=default) class Migration(migrations.Migration): operations = [ migrations.RunPython(set_default_industry), migrations.AlterField( model_name='site', name='industry', field=models.ForeignKey( 'igny8_core_auth.Industry', on_delete=models.PROTECT, related_name='sites' ), ), ] ``` #### 1.4 Update Free Plan Credits ```bash docker exec igny8_backend python manage.py shell ``` ```python from igny8_core.auth.models import Plan plan = Plan.objects.get(slug='free') plan.included_credits = 1000 plan.save() print(f"Updated free plan credits: {plan.included_credits}") ``` **Testing Phase 1:** ```bash # Test 1: Free trial signup curl -X POST http://localhost:8000/api/v1/auth/register/ \ -H "Content-Type: application/json" \ -d '{ "email": "test@example.com", "password": "Test123!", "password_confirm": "Test123!", "first_name": "Test", "last_name": "User" }' # Expected: ✅ Success, 1000 credits # Test 2: Paid signup (should not crash) curl -X POST http://localhost:8000/api/v1/auth/register/ \ -H "Content-Type: application/json" \ -d '{ "email": "paid@example.com", "password": "Test123!", "password_confirm": "Test123!", "first_name": "Paid", "last_name": "User", "plan_slug": "starter" }' # Expected: ✅ Success, subscription created with plan_id ``` --- ### PHASE 2: Model Cleanup (2-3 Days) **Priority:** 🟡 Important #### 2.1 Remove Duplicate Fields from Invoice ```bash python manage.py makemigrations --name remove_duplicate_invoice_fields ``` ```python class Migration(migrations.Migration): operations = [ migrations.RemoveField('invoice', 'billing_period_start'), migrations.RemoveField('invoice', 'billing_period_end'), migrations.RemoveField('invoice', 'billing_email'), ] ``` #### 2.2 Add Properties to Invoice Model ```python # File: backend/igny8_core/business/billing/models.py class Invoice(AccountBaseModel): # ... existing fields ... @property def billing_period_start(self): """Get from subscription""" return self.subscription.current_period_start if self.subscription else None @property def billing_period_end(self): """Get from subscription""" return self.subscription.current_period_end if self.subscription else None @property def billing_email(self): """Get from metadata or account""" snapshot = self.metadata.get('billing_snapshot', {}) return snapshot.get('email') or self.account.billing_email ``` #### 2.3 Remove Duplicate payment_method from Subscription ```bash python manage.py makemigrations --name remove_subscription_payment_method ``` ```python class Migration(migrations.Migration): operations = [ migrations.RemoveField('subscription', 'payment_method'), ] ``` #### 2.4 Add payment_method Property to Subscription ```python # File: backend/igny8_core/auth/models.py class Subscription(models.Model): # ... existing fields ... @property def payment_method(self): """Get from account's default payment method""" return self.account.payment_method ``` #### 2.5 Convert Account.payment_method to Property ```python # File: backend/igny8_core/auth/models.py class Account(SoftDeletableModel): # Keep field for backward compatibility but make it read-only in forms # Eventually remove via migration @property def default_payment_method(self): """Get default payment method from AccountPaymentMethod""" method = self.accountpaymentmethod_set.filter( is_default=True, is_enabled=True ).first() return method.type if method else 'stripe' ``` #### 2.6 Remove transaction_reference from Payment ```bash python manage.py makemigrations --name remove_duplicate_payment_reference ``` ```python class Migration(migrations.Migration): operations = [ migrations.RemoveField('payment', 'transaction_reference'), ] ``` --- ### PHASE 3: New Features (3-5 Days) **Priority:** 🟢 Enhancement #### 3.1 Create PaymentMethodConfig Default Data ```bash docker exec igny8_backend python manage.py shell ``` ```python from igny8_core.business.billing.models import PaymentMethodConfig # Global methods (available worldwide) PaymentMethodConfig.objects.get_or_create( country_code='*', payment_method='stripe', defaults={ 'is_enabled': True, 'display_name': 'Credit/Debit Card (Stripe)', 'sort_order': 1 } ) PaymentMethodConfig.objects.get_or_create( country_code='*', payment_method='paypal', defaults={ 'is_enabled': True, 'display_name': 'PayPal', 'sort_order': 2 } ) # UK, USA, Canada, Europe - Bank Transfer for country in ['GB', 'US', 'CA', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'IE', 'SE', 'NO', 'DK', 'FI']: PaymentMethodConfig.objects.get_or_create( country_code=country, payment_method='bank_transfer', defaults={ 'is_enabled': True, 'display_name': 'Bank Transfer', 'instructions': ''' Bank Name: ABC Bank Account Name: IGNY8 Inc Account Number: 123456789 SWIFT/BIC: ABCPKKA IBAN: GB00ABCD12345678901234 Please transfer the exact invoice amount and include your invoice number as reference. ''', 'sort_order': 3 } ) # Pakistan - Local Wallet (JazzCash/Easypaisa) PaymentMethodConfig.objects.get_or_create( country_code='PK', payment_method='local_wallet', defaults={ 'is_enabled': True, 'display_name': 'JazzCash / Easypaisa', 'wallet_type': 'JazzCash', 'wallet_id': '03001234567', 'instructions': ''' Send payment to: JazzCash: 03001234567 Account Title: IGNY8 After payment: 1. Note the Transaction ID 2. Submit confirmation below with Transaction ID 3. Upload screenshot (optional) ''', 'sort_order': 4 } ) print("Payment method configurations created!") print("- Global: Stripe, PayPal") print("- UK/USA/Canada/Europe: + Bank Transfer") print("- Pakistan: + JazzCash/Easypaisa") ``` #### 3.2 Create Payment Methods API Endpoint ```python # File: backend/igny8_core/business/billing/views.py @action(detail=False, methods=['get'], url_path='payment-methods') def list_payment_methods(self, request): """Get available payment methods for user's country""" country = request.GET.get('country', '*') # Get country-specific + global methods from django.db.models import Q methods = PaymentMethodConfig.objects.filter( Q(country_code=country) | Q(country_code='*'), is_enabled=True ).order_by('sort_order') from .serializers import PaymentMethodConfigSerializer return Response(PaymentMethodConfigSerializer(methods, many=True).data) ``` #### 3.3 Create Payment Confirmation Endpoint ```python # File: backend/igny8_core/business/billing/views.py @action(detail=False, methods=['post'], url_path='payments/confirm') def confirm_payment(self, request): """User confirms manual payment with reference""" invoice_id = request.data.get('invoice_id') manual_reference = request.data.get('manual_reference') manual_notes = request.data.get('manual_notes', '') proof_url = request.data.get('proof_url') if not invoice_id or not manual_reference: return error_response( error='invoice_id and manual_reference required', status_code=400, request=request ) try: invoice = Invoice.objects.get( id=invoice_id, account=request.account ) payment = Payment.objects.create( account=request.account, invoice=invoice, amount=invoice.total, currency=invoice.currency, status='pending_approval', payment_method=invoice.payment_method or 'bank_transfer', manual_reference=manual_reference, manual_notes=manual_notes, metadata={'proof_url': proof_url} if proof_url else {} ) # Send notification to admin send_payment_confirmation_notification(payment) return success_response( data={'payment_id': payment.id, 'status': 'pending_approval'}, message='Payment confirmation submitted for review', request=request ) except Invoice.DoesNotExist: return error_response( error='Invoice not found', status_code=404, request=request ) ``` --- **NOTE:** RegisterSerializer already handles signup correctly with existing billing fields in Account model. No changes needed. --- #### 3.4 Admin Payment Approval Interface ```python # File: backend/igny8_core/business/billing/admin.py from django.contrib import admin from .models import Payment @admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): list_display = ['id', 'account', 'amount', 'status', 'payment_method', 'created_at'] list_filter = ['status', 'payment_method', 'created_at'] search_fields = ['account__name', 'manual_reference', 'external_payment_id'] actions = ['approve_payments', 'reject_payments'] def approve_payments(self, request, queryset): """Approve selected payments""" count = 0 for payment in queryset.filter(status='pending_approval'): try: # Call existing approval endpoint from .views import BillingViewSet viewset = BillingViewSet() viewset.request = request viewset.approve_payment_internal(payment) count += 1 except Exception as e: self.message_user( request, f"Error approving payment {payment.id}: {str(e)}", level='ERROR' ) self.message_user( request, f"Successfully approved {count} payment(s)" ) approve_payments.short_description = "Approve selected payments" def reject_payments(self, request, queryset): """Reject selected payments""" count = queryset.filter( status='pending_approval' ).update(status='failed', metadata={'rejected_by': request.user.id}) self.message_user(request, f"Rejected {count} payment(s)") reject_payments.short_description = "Reject selected payments" ``` --- ### PHASE 4: Testing & Validation (1-2 Days) **Priority:** 🔵 Quality Assurance #### 4.1 Test: Free Trial Signup End-to-End ```bash # 1. Frontend: Go to /signup # 2. Fill form: email, password, name # 3. Submit # 4. Verify: Account created with 1000 credits # 5. Verify: Subscription.plan_id = 1 (Free plan) # 6. Verify: Can create 1 site # 7. Verify: Site requires industry selection # 8. Verify: Can access site dashboard ``` **Database Verification:** ```sql SELECT a.id, a.email, a.status, a.credits, s.plan_id, s.status, s.current_period_start, p.slug, p.included_credits FROM igny8_tenants a JOIN igny8_subscriptions s ON s.tenant_id = a.id JOIN igny8_plans p ON s.plan_id = p.id WHERE a.email = 'test@example.com'; -- Expected: credits=1000, plan_id=1, plan.slug='free' ``` #### 4.2 Test: Paid Signup with Bank Transfer ```bash # 1. Frontend: /signup?plan=starter # 2. Fill signup form # 3. Step 2: Fill billing form # - billing_email: billing@company.com # - billing_country: PK # - payment_method: bank_transfer # 4. Submit # 5. Verify: Account created with status='pending_payment' # 6. Verify: Invoice created with total=5000 # 7. Verify: Payment method instructions shown # 8. User submits payment with reference: BT-12345 # 9. Verify: Payment status='pending_approval' # 10. Admin approves payment # 11. Verify: Account status='active', credits=5000 # 12. User can now create 3 sites ``` **API Test:** ```bash # Step 1: Register with paid plan curl -X POST http://localhost:8000/api/v1/auth/register/ \ -H "Content-Type: application/json" \ -d '{ "email": "paid@test.com", "password": "Test123!", "password_confirm": "Test123!", "first_name": "Paid", "last_name": "User", "plan_slug": "starter", "billing_email": "billing@test.com", "billing_country": "PK", "payment_method": "bank_transfer" }' # Expected response: # { # "success": true, # "message": "Account created. Please complete payment.", # "data": { # "account_id": 2, # "invoice_id": 2, # "amount": 5000, # "payment_method": "bank_transfer", # "instructions": "Bank transfer details..." # } # } # Step 2: Confirm payment curl -X POST http://localhost:8000/api/v1/billing/payments/confirm/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "invoice_id": 2, "manual_reference": "BT-20251208-12345", "manual_notes": "Transferred via ABC Bank on Dec 8" }' # Expected: # { # "success": true, # "message": "Payment confirmation submitted for review", # "data": {"payment_id": 1, "status": "pending_approval"} # } ``` #### 4.3 Test: Site Creation with Industry ```bash # After account activated, create site curl -X POST http://localhost:8000/api/v1/auth/sites/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "My Tech Blog", "domain": "https://mytechblog.com", "industry": 2, "site_type": "blog" }' # Expected: ✅ Site created # Test without industry (should fail) curl -X POST http://localhost:8000/api/v1/auth/sites/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "My Blog", "domain": "https://myblog.com" }' # Expected: ❌ 400 Bad Request, "industry is required" ``` #### 4.4 Test: Payment Method Filtering by Country ```bash # Get payment methods for Pakistan curl http://localhost:8000/api/v1/billing/payment-methods/?country=PK # Expected: # [ # {"type": "stripe", "display_name": "Credit/Debit Card (Stripe)"}, # {"type": "paypal", "display_name": "PayPal"}, # {"type": "bank_transfer", "display_name": "Bank Transfer", "instructions": "..."}, # {"type": "local_wallet", "display_name": "JazzCash / Easypaisa", "instructions": "..."} # ] # Get payment methods for USA curl http://localhost:8000/api/v1/billing/payment-methods/?country=US # Expected (only global methods): # [ # {"type": "stripe", "display_name": "Credit/Debit Card (Stripe)"}, # {"type": "paypal", "display_name": "PayPal"}, # {"type": "bank_transfer", "display_name": "Bank Transfer"} # ] ``` --- ## Summary: Complete Workflow After Implementation ``` FREE TRIAL PATH: User → /signup → Fill form → Submit → Account created (1000 credits) → Create site (industry required) → Add sectors → Start using PAID PLAN PATH: User → /signup?plan=starter → Fill signup form → Fill billing form → Submit → Account created (pending_payment) → Invoice generated → User sees payment instructions → Transfer payment → Submit confirmation → Admin receives notification → Admin approves → Account activated (5000 credits) → Create sites (up to 3) → Add sectors → Start using ``` **Key Improvements:** ✅ Subscription.plan field tracks historical plan ✅ No duplicate date fields (Invoice uses Subscription dates) ✅ Single source of truth for payment methods (AccountPaymentMethod) ✅ Billing fields captured and saved during signup ✅ Country-specific payment methods shown ✅ Manual payment confirmation workflow ✅ Admin approval triggers atomic updates across all tables ✅ Site.industry is required ✅ SiteUserAccess created automatically ✅ Free plan has correct 1000 credits **Database Reduction:** - Before: 109 fields across models - After: 105 fields (-4 duplicate fields) - Relationships cleaner: payment_method in 1 place instead of 4 **Testing Coverage:** - ✅ Free trial signup (frontend → API → database) - ✅ Paid plan signup with billing form - ✅ Payment confirmation flow - ✅ Admin approval workflow - ✅ Site creation with industry requirement - ✅ Payment method filtering by country - ✅ Credit allocation and transaction logging --- ## Implementation Timeline | Phase | Duration | Tasks | |-------|----------|-------| | **Phase 1** | 1 day | Fix import, add Subscription.plan, make Site.industry required, update free plan credits | | **Phase 2** | 2-3 days | Remove duplicate fields, add properties, consolidate payment methods | | **Phase 3** | 3-5 days | Create payment methods API, payment confirmation, billing form, admin interface | | **Phase 4** | 1-2 days | End-to-end testing, validation, bug fixes | | **Total** | **7-11 days** | Complete implementation with testing | **Critical Path:** 1. Day 1: Phase 1 (fixes blocking production) 2. Day 2-4: Phase 2 (cleanup duplicate fields) 3. Day 5-9: Phase 3 (new features) 4. Day 10-11: Phase 4 (testing) --- ## Appendix: Quick Reference ### Model Changes Summary ```python # Subscription + plan = FK(Plan) # NEW - for historical tracking - payment_method # REMOVE (use property from AccountPaymentMethod) # Invoice + pdf_file = FileField # NEW - generated PDF storage - billing_period_start # REMOVE (use property from subscription) - billing_period_end # REMOVE (use property from subscription) - billing_email # REMOVE (use property from metadata/account) # Payment + is_current_period = BooleanField # NEW - flag for current billing period - transaction_reference # REMOVE (duplicate of manual_reference) # Account + billing_company, tax_id, billing_phone, billing_state, billing_postal_code # NEW optional fields ! credits - SINGLE SOURCE, populated from plan.included_credits # Site ! industry = FK(Industry, null=False) # MAKE REQUIRED # Plan ! included_credits - SOURCE OF TRUTH for all credit amounts ``` ### API Endpoints - New & Updated **New Endpoints:** ``` GET /v1/billing/payment-methods/?country={code} # Filter methods by region POST /v1/billing/payments/confirm/ # Manual payment confirmation PATCH /v1/auth/account/billing # Update full billing details GET /v1/billing/payment-history # Complete payment history ``` **Existing Endpoints (Already Implemented):** ``` POST /v1/auth/register/ # ✅ EXISTS - need to enhance with minimal billing GET /v1/billing/invoices/{id}/download_pdf/ # ✅ EXISTS - need to serve from pdf_file field POST /v1/billing/confirm-bank-transfer # ✅ EXISTS - admin manual payment approval POST /v1/billing/payments/manual # ✅ EXISTS - user submits manual payment ``` **Payment Method Regional Configuration:** ``` Global (all countries): Stripe, PayPal UK/USA/Canada/Europe: + Bank Transfer Pakistan: + JazzCash/Easypaisa (local_wallet) ``` ### Database Queries for Verification ```sql -- Verify subscription has plan SELECT s.id, s.tenant_id, s.plan_id, p.slug FROM igny8_subscriptions s JOIN igny8_plans p ON s.plan_id = p.id; -- Verify no duplicate payment methods SELECT a.id AS account_id, apm.type AS default_method, apm.is_default FROM igny8_tenants a LEFT JOIN igny8_account_payment_methods apm ON apm.tenant_id = a.id AND apm.is_default = TRUE; -- Verify all sites have industry SELECT id, name, industry_id FROM igny8_sites WHERE industry_id IS NULL; -- Expected: 0 rows -- Verify credit transactions match account credits SELECT a.id, a.credits AS account_total, COALESCE(SUM(ct.amount), 0) AS transaction_total FROM igny8_tenants a LEFT JOIN igny8_credit_transactions ct ON ct.tenant_id = a.id GROUP BY a.id, a.credits HAVING a.credits != COALESCE(SUM(ct.amount), 0); -- Expected: 0 rows (all match) ``` --- **NOTE:** Account model already has all billing fields (billing_email, billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country). No new fields needed. --- **NOTE:** Invoices already provide complete payment history. The InvoiceViewSet.list() endpoint returns all invoices with payment status. No separate payment history needed. --- #### 3.5 Invoice PDF Generation & Download ```python # File: backend/igny8_core/business/billing/models.py class Invoice(AccountBaseModel): # ... existing fields ... # ADD NEW FIELD pdf_file = models.FileField( upload_to='invoices/pdf/%Y/%m/', null=True, blank=True, help_text='Generated PDF invoice file' ) def generate_pdf(self): """Generate PDF and save to file field""" from .services.invoice_service import InvoiceService pdf_bytes = InvoiceService.generate_pdf(self) # Save to file field from django.core.files.base import ContentFile filename = f'invoice-{self.invoice_number}.pdf' self.pdf_file.save(filename, ContentFile(pdf_bytes), save=True) return self.pdf_file.url def save(self, *args, **kwargs): """Auto-generate PDF after invoice is saved""" super().save(*args, **kwargs) # Generate PDF for paid/draft invoices if self.status in ['paid', 'draft', 'sent'] and not self.pdf_file: self.generate_pdf() ``` **Enhanced PDF Service:** ```python # File: backend/igny8_core/business/billing/services/invoice_service.py from reportlab.lib.pagesizes import letter, A4 from reportlab.lib import colors from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image from reportlab.lib.units import inch from io import BytesIO from django.conf import settings import os class InvoiceService: @staticmethod def generate_pdf(invoice): """Generate professional PDF invoice""" buffer = BytesIO() doc = SimpleDocTemplate(buffer, pagesize=A4, leftMargin=0.75*inch, rightMargin=0.75*inch, topMargin=0.75*inch, bottomMargin=0.75*inch) elements = [] styles = getSampleStyleSheet() # Company Header if os.path.exists(os.path.join(settings.STATIC_ROOT, 'logo.png')): logo = Image(os.path.join(settings.STATIC_ROOT, 'logo.png'), width=2*inch, height=0.5*inch) elements.append(logo) elements.append(Spacer(1, 0.3*inch)) # Invoice Title title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=24, textColor=colors.HexColor('#1a202c'), spaceAfter=30, ) elements.append(Paragraph('INVOICE', title_style)) # Invoice Info Table info_data = [ ['Invoice Number:', invoice.invoice_number], ['Invoice Date:', invoice.invoice_date.strftime('%B %d, %Y')], ['Due Date:', invoice.due_date.strftime('%B %d, %Y')], ['Status:', invoice.status.upper()], ] if invoice.paid_at: info_data.append(['Paid On:', invoice.paid_at.strftime('%B %d, %Y')]) info_table = Table(info_data, colWidths=[2*inch, 3*inch]) info_table.setStyle(TableStyle([ ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ])) elements.append(info_table) elements.append(Spacer(1, 0.4*inch)) # Billing Info billing_style = ParagraphStyle( 'BillingStyle', parent=styles['Normal'], fontSize=10, leading=14, ) bill_to = f"Bill To:
" if invoice.account.billing_company: bill_to += f"{invoice.account.billing_company}
" bill_to += f"{invoice.account.billing_email}
" if invoice.account.billing_address: bill_to += f"{invoice.account.billing_address}
" if invoice.account.billing_city: bill_to += f"{invoice.account.billing_city}, " if invoice.account.billing_state: bill_to += f"{invoice.account.billing_state} " if invoice.account.billing_postal_code: bill_to += f"{invoice.account.billing_postal_code}
" if invoice.account.billing_country: bill_to += f"{invoice.account.billing_country}
" if invoice.account.tax_id: bill_to += f"Tax ID: {invoice.account.tax_id}" elements.append(Paragraph(bill_to, billing_style)) elements.append(Spacer(1, 0.4*inch)) # Line Items Table line_items_data = [['Description', 'Quantity', 'Unit Price', 'Amount']] for item in invoice.line_items: line_items_data.append([ item.get('description', ''), str(item.get('quantity', 1)), f"${item.get('unit_price', 0):.2f}", f"${item.get('amount', 0):.2f}" ]) # Totals line_items_data.append(['', '', 'Subtotal:', f"${invoice.subtotal:.2f}"]) if invoice.tax > 0: line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f"${invoice.tax:.2f}"]) if invoice.discount > 0: line_items_data.append(['', '', 'Discount:', f"-${invoice.discount:.2f}"]) line_items_data.append(['', '', 'Total:', f"${invoice.total:.2f}"]) line_table = Table(line_items_data, colWidths=[3.5*inch, 1*inch, 1.5*inch, 1.5*inch]) line_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f7fafc')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#2d3748')), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('ALIGN', (1, 1), (-1, -1), 'RIGHT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 11), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), colors.white), ('GRID', (0, 0), (-1, -4), 0.5, colors.grey), ('LINEABOVE', (2, -3), (-1, -3), 1, colors.black), ('LINEABOVE', (2, -1), (-1, -1), 2, colors.black), ('FONTNAME', (2, -1), (-1, -1), 'Helvetica-Bold'), ('FONTSIZE', (2, -1), (-1, -1), 12), ])) elements.append(line_table) elements.append(Spacer(1, 0.5*inch)) # Payment Info if invoice.notes: notes_style = ParagraphStyle( 'NotesStyle', parent=styles['Normal'], fontSize=9, textColor=colors.HexColor('#4a5568'), ) elements.append(Paragraph(f"Notes: {invoice.notes}", notes_style)) # Footer elements.append(Spacer(1, 0.5*inch)) footer_style = ParagraphStyle( 'FooterStyle', parent=styles['Normal'], fontSize=8, textColor=colors.grey, alignment=1, # Center ) elements.append(Paragraph('Thank you for your business!', footer_style)) elements.append(Paragraph('IGNY8 Inc. | support@igny8.com', footer_style)) # Build PDF doc.build(elements) buffer.seek(0) return buffer.getvalue() ``` **Updated Download Endpoint (already exists):** ```python # File: backend/igny8_core/business/billing/views.py @action(detail=True, methods=['get']) def download_pdf(self, request, pk=None): """Download invoice PDF - serves from file field if exists""" try: invoice = self.get_queryset().get(pk=pk) # Return existing PDF file if available if invoice.pdf_file: response = HttpResponse(invoice.pdf_file.read(), content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"' return response # Generate on-the-fly if not exists pdf_bytes = InvoiceService.generate_pdf(invoice) response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"' return response except Invoice.DoesNotExist: return error_response(error='Invoice not found', status_code=404, request=request) ``` **Migration for pdf_file field:** ```bash python manage.py makemigrations --name add_invoice_pdf_field ``` ```python # Migration file class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='invoice', name='pdf_file', field=models.FileField( upload_to='invoices/pdf/%Y/%m/', null=True, blank=True, help_text='Generated PDF invoice file' ), ), ] ``` --- #### 3.9 Credits Single Source of Truth ```python # File: backend/igny8_core/auth/models.py class Plan(models.Model): """ Plan model - SOURCE OF TRUTH for credit amounts """ included_credits = models.IntegerField( default=0, help_text='Monthly credits included in plan (source of truth)' ) # ... other fields ... class Account(SoftDeletableModel): """ Account model - STORES CURRENT BALANCE only Credits populated from Plan.included_credits """ plan = models.ForeignKey(Plan, on_delete=models.PROTECT) credits = models.IntegerField( default=0, help_text='Current credit balance (populated from plan.included_credits)' ) @property def monthly_allowance(self): """Monthly allowance always from plan""" return self.plan.included_credits @property def total_credits_this_period(self): """Total credits for current billing period""" return self.plan.included_credits def reset_monthly_credits(self): """Reset credits at billing cycle - ALWAYS from plan""" self.credits = self.plan.included_credits self.save(update_fields=['credits']) # Log the reset CreditTransaction.objects.create( account=self, transaction_type='monthly_reset', amount=self.plan.included_credits, balance_after=self.credits, description=f'Monthly credit reset - {self.plan.name} plan', metadata={'plan_id': self.plan.id, 'included_credits': self.plan.included_credits} ) class Subscription(models.Model): """ Subscription - NO credit fields, uses account.credits """ account = models.OneToOneField(Account, on_delete=models.CASCADE) plan = models.ForeignKey(Plan, on_delete=models.PROTECT) # For historical tracking # NO credits field here @property def remaining_credits(self): """Remaining credits from account""" return self.account.credits @property def monthly_allowance(self): """Monthly allowance from plan""" return self.plan.included_credits class Invoice(AccountBaseModel): """ Invoice - NO credit fields, reference from subscription.plan """ subscription = models.ForeignKey(Subscription, on_delete=models.PROTECT, null=True) # NO included_credits field @property def plan_credits(self): """Credits from subscription's plan (historical snapshot)""" return self.subscription.plan.included_credits if self.subscription else 0 ``` **Credit Flow Documentation:** ``` SINGLE SOURCE OF TRUTH: Plan.included_credits Plan.included_credits (1000) │ ├─→ Account.credits (populated on signup/renewal) │ └─→ Updated by CreditService.add_credits() / deduct_credits() │ ├─→ Account.monthly_allowance (property, reads from plan) │ └─→ Subscription.plan.included_credits (historical record) └─→ Invoice.plan_credits (property, from subscription.plan) RULES: 1. Plan.included_credits = NEVER changes unless admin updates plan 2. Account.credits = CURRENT balance (can be spent/added to) 3. Account.monthly_allowance = ALWAYS reads from plan (property) 4. Monthly reset: Account.credits = Plan.included_credits 5. Invoice references: Use subscription.plan.included_credits (snapshot) ``` **CreditService enforcement:** ```python # File: backend/igny8_core/business/billing/services/credit_service.py class CreditService: @staticmethod def add_credits(account, amount, description, transaction_type='manual'): """Add credits - updates ONLY account.credits""" with transaction.atomic(): account.credits += amount account.save(update_fields=['credits']) CreditTransaction.objects.create( account=account, transaction_type=transaction_type, amount=amount, balance_after=account.credits, description=description ) @staticmethod def reset_monthly_credits(account): """Reset to plan amount - SINGLE SOURCE""" new_balance = account.plan.included_credits with transaction.atomic(): account.credits = new_balance account.save(update_fields=['credits']) CreditTransaction.objects.create( account=account, transaction_type='monthly_reset', amount=new_balance, balance_after=new_balance, description=f'Monthly reset - {account.plan.name} plan', metadata={ 'plan_id': account.plan.id, 'plan_credits': account.plan.included_credits } ) ``` ```python # File: backend/igny8_core/business/billing/models.py class Payment(AccountBaseModel): """ Payment model - handles both one-time and recurring payments STRATEGY: - external_payment_id: Current active payment reference (Stripe sub ID, transaction ID) - manual_reference: User-provided reference for manual payments - invoice: Links payment to specific billing period - Multiple payments can exist for same subscription (historical) """ invoice = models.ForeignKey( Invoice, on_delete=models.PROTECT, related_name='payments', help_text='Invoice this payment is for (links to billing period)' ) external_payment_id = models.CharField( max_length=255, blank=True, help_text='External payment reference (Stripe charge ID, PayPal txn ID, etc.)' ) manual_reference = models.CharField( max_length=255, blank=True, help_text='User-provided reference for manual payments (bank transfer ID, etc.)' ) is_current_period = models.BooleanField( default=False, help_text='Is this payment for the current active billing period?' ) @classmethod def get_current_period_payment(cls, account): """Get active payment for current billing period""" return cls.objects.filter( account=account, is_current_period=True, status='succeeded' ).first() @classmethod def get_payment_history(cls, account): """Get all historical payments""" return cls.objects.filter( account=account ).select_related('invoice').order_by('-created_at') ``` **Recurring Payment Workflow:** ```python # File: backend/igny8_core/business/billing/services/subscription_service.py class SubscriptionService: @staticmethod def renew_subscription(subscription): """ Renew subscription - creates NEW invoice and payment Previous payments remain in history """ with transaction.atomic(): # Mark old payment as historical Payment.objects.filter( account=subscription.account, is_current_period=True ).update(is_current_period=False) # Create new invoice for new period new_invoice = Invoice.objects.create( account=subscription.account, subscription=subscription, invoice_number=f'INV-{timezone.now().strftime("%Y%m%d")}-{subscription.account.id}', total=subscription.plan.price, subtotal=subscription.plan.price, status='pending' ) # Update subscription period subscription.current_period_start = timezone.now() subscription.current_period_end = timezone.now() + timedelta(days=30) subscription.save() # If auto-pay enabled, create payment if subscription.account.payment_method in ['stripe', 'paypal']: payment = Payment.objects.create( account=subscription.account, invoice=new_invoice, amount=new_invoice.total, payment_method=subscription.account.payment_method, status='processing', is_current_period=True ) # Process payment via gateway # ... payment processing logic ... return new_invoice ``` **Payment History View:** ```python # File: backend/igny8_core/business/billing/views.py @action(detail=False, methods=['get'], url_path='payment-history') def payment_history(self, request): """Get complete payment history with invoice details""" payments = Payment.objects.filter( account=request.account ).select_related('invoice', 'invoice__subscription').order_by('-created_at') results = [] for payment in payments: results.append({ 'id': payment.id, 'invoice_number': payment.invoice.invoice_number, 'amount': str(payment.amount), 'status': payment.status, 'payment_method': payment.payment_method, 'reference': payment.external_payment_id or payment.manual_reference, 'is_current': payment.is_current_period, 'billing_period': { 'start': payment.invoice.subscription.current_period_start.isoformat(), 'end': payment.invoice.subscription.current_period_end.isoformat() } if payment.invoice.subscription else None, 'paid_at': payment.processed_at.isoformat() if payment.processed_at else None, }) return success_response(data={'payments': results}, request=request) ``` **Frontend Payment History:** ```typescript // File: frontend/src/pages/BillingHistory.tsx export const PaymentHistory = () => { const [payments, setPayments] = useState([]); return (

Payment History

{payments.map(payment => ( ))}
Date Invoice Amount Method Reference Status Period
{formatDate(payment.paid_at)} {payment.invoice_number} ${payment.amount} {payment.payment_method} {payment.reference} {payment.status} {payment.is_current && Current} {payment.billing_period ? `${formatDate(payment.billing_period.start)} - ${formatDate(payment.billing_period.end)}` : 'N/A'}
); }; ```