127 KiB
IMPLEMENTATION PLAN: Clean Signup to Payment Workflow
Version: 2.0
Date: December 8, 2025
Status: 🔧 READY FOR IMPLEMENTATION
Table of Contents
- Executive Summary
- Deep Analysis Findings
- Field & Relationship Audit
- Simplified Model Architecture
- Complete Workflow Diagrams
- Implementation Phases
- Database Migrations
- Testing Plan
Executive Summary
Current State Analysis
Critical Issues Found:
- Duplicate Date Fields - Period dates stored in 2 places (Subscription + Invoice)
- Payment Method Chaos - Stored in 3 different models with no single source of truth
- Missing Relationships - Subscription has no
planfield, breaking invoice creation - Unused Payment References - No
external_payment_idcaptured for manual payments - Profile Fields Not Updated - Billing info never synced from account to invoices
- Country-Specific Logic Missing - Pakistan payment methods not filtered
- Site Industry Not Required - Can create sites without industry, then can't add sectors
- Broken Paid Signup Flow - Imports non-existent
billing.Subscription - Credits Not Single Source - Account.credits, Plan.included_credits not synchronized
- Invoice PDF Missing - No pdf_file field, no proper PDF generation/download
- 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:
# 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:
# 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:
# 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_methodand createsAccountPaymentMethod - 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:
# 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:
# 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:
# InvoiceService.create_subscription_invoice()
subscription.plan # ❌ AttributeError: 'Subscription' object has no attribute 'plan'
Why It's Missing:
- Plan stored in
Account.planonly - 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:
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:
# 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_referenceandtransaction_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:
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:
# 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:
# 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:
# 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:
# 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:
# 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:
# 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:
# 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:
# auth/serializers.py line 291
from igny8_core.business.billing.models import Subscription # ❌ DOES NOT EXIST
Fix:
# Use existing auth.Subscription
from igny8_core.auth.models import Subscription # ✓ EXISTS
Field & Relationship Audit
Complete Field Inventory
Subscription Model - BEFORE Cleanup
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
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:
planfield (FK to Plan) - ❌ Removed:
payment_methodfield (use property instead) - Field count: 11 → 11 (same, but cleaner relationships)
Invoice Model - BEFORE Cleanup
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
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:
-- 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):
-- 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:
-- 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:
-- 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:
-- 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
# 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
cd backend
python manage.py makemigrations --name add_subscription_plan_field
# Migration file created: 0XXX_add_subscription_plan_field.py
# 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'
),
),
]
# Apply migration
python manage.py migrate
1.3 Make Site.industry Required
python manage.py makemigrations --name make_site_industry_required
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
docker exec igny8_backend python manage.py shell
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:
# 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
python manage.py makemigrations --name remove_duplicate_invoice_fields
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
# 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
python manage.py makemigrations --name remove_subscription_payment_method
class Migration(migrations.Migration):
operations = [
migrations.RemoveField('subscription', 'payment_method'),
]
2.4 Add payment_method Property to Subscription
# 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
# 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
python manage.py makemigrations --name remove_duplicate_payment_reference
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
docker exec igny8_backend python manage.py shell
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
# 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
# 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
# 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
# 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:
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
# 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:
# 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 <token>" \
-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
# After account activated, create site
curl -X POST http://localhost:8000/api/v1/auth/sites/ \
-H "Authorization: Bearer <token>" \
-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 <token>" \
-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
# 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:
- Day 1: Phase 1 (fixes blocking production)
- Day 2-4: Phase 2 (cleanup duplicate fields)
- Day 5-9: Phase 3 (new features)
- Day 10-11: Phase 4 (testing)
Appendix: Quick Reference
Model Changes Summary
# 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
-- 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
# 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:
# 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"<b>Bill To:</b><br/>"
if invoice.account.billing_company:
bill_to += f"{invoice.account.billing_company}<br/>"
bill_to += f"{invoice.account.billing_email}<br/>"
if invoice.account.billing_address:
bill_to += f"{invoice.account.billing_address}<br/>"
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}<br/>"
if invoice.account.billing_country:
bill_to += f"{invoice.account.billing_country}<br/>"
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"<b>Notes:</b> {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):
# 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:
python manage.py makemigrations --name add_invoice_pdf_field
# 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
# 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:
# 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
}
)
# 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:
# 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:
# 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:
// File: frontend/src/pages/BillingHistory.tsx
export const PaymentHistory = () => {
const [payments, setPayments] = useState([]);
return (
<div>
<h2>Payment History</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Invoice</th>
<th>Amount</th>
<th>Method</th>
<th>Reference</th>
<th>Status</th>
<th>Period</th>
</tr>
</thead>
<tbody>
{payments.map(payment => (
<tr key={payment.id} className={payment.is_current ? 'current-period' : ''}>
<td>{formatDate(payment.paid_at)}</td>
<td>{payment.invoice_number}</td>
<td>${payment.amount}</td>
<td>{payment.payment_method}</td>
<td>{payment.reference}</td>
<td>
<Badge color={payment.status === 'succeeded' ? 'green' : 'yellow'}>
{payment.status}
</Badge>
{payment.is_current && <Badge color="blue">Current</Badge>}
</td>
<td>
{payment.billing_period ?
`${formatDate(payment.billing_period.start)} - ${formatDate(payment.billing_period.end)}`
: 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};