2489 lines
109 KiB
Markdown
2489 lines
109 KiB
Markdown
# 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`
|
|
|
|
**Complexity Issues:**
|
|
- 4 global payment methods + 1 country-specific = unnecessary complexity
|
|
- 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
|
|
|
|
### 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 (3):** Stripe, PayPal, Bank Transfer
|
|
- **Country-Specific (1):** Local Wallet (Pakistan only)
|
|
- **Total:** 4 payment methods (removed unnecessary complexity)
|
|
|
|
---
|
|
|
|
## 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 everywhere)
|
|
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
|
|
}
|
|
)
|
|
|
|
PaymentMethodConfig.objects.get_or_create(
|
|
country_code='*',
|
|
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: ABCPKKA
|
|
|
|
Please transfer the exact invoice amount and keep the transaction reference.
|
|
''',
|
|
'sort_order': 3
|
|
}
|
|
)
|
|
|
|
# Pakistan-specific
|
|
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 Name: IGNY8
|
|
|
|
Please keep the transaction ID and confirm payment after sending.
|
|
''',
|
|
'sort_order': 4
|
|
}
|
|
)
|
|
|
|
print("Payment method configurations created!")
|
|
```
|
|
|
|
#### 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
|
|
)
|
|
```
|
|
|
|
|
|
#### 3.4 Update RegisterSerializer for Billing Fields
|
|
```python
|
|
# File: backend/igny8_core/auth/serializers.py
|
|
|
|
class RegisterSerializer(serializers.ModelSerializer):
|
|
# ... existing fields ...
|
|
|
|
# Add billing fields
|
|
billing_email = serializers.EmailField(required=False, allow_blank=True)
|
|
billing_address = serializers.CharField(required=False, allow_blank=True)
|
|
billing_city = serializers.CharField(required=False, allow_blank=True)
|
|
billing_country = serializers.CharField(required=False, allow_blank=True)
|
|
payment_method = serializers.CharField(required=False, default='stripe')
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
'email', 'password', 'password_confirm', 'first_name', 'last_name',
|
|
'billing_email', 'billing_address', 'billing_city', 'billing_country',
|
|
'payment_method', 'plan_slug'
|
|
]
|
|
|
|
def create(self, validated_data):
|
|
# ... existing account/user creation ...
|
|
|
|
# Update billing fields if provided
|
|
billing_email = validated_data.pop('billing_email', None)
|
|
billing_address = validated_data.pop('billing_address', None)
|
|
billing_city = validated_data.pop('billing_city', None)
|
|
billing_country = validated_data.pop('billing_country', None)
|
|
payment_method = validated_data.pop('payment_method', 'stripe')
|
|
|
|
if billing_email:
|
|
account.billing_email = billing_email
|
|
if billing_address:
|
|
account.billing_address = billing_address
|
|
if billing_city:
|
|
account.billing_city = billing_city
|
|
if billing_country:
|
|
account.billing_country = billing_country
|
|
|
|
account.save(update_fields=[
|
|
'billing_email', 'billing_address', 'billing_city', 'billing_country'
|
|
])
|
|
|
|
# Create AccountPaymentMethod if not free trial
|
|
if not is_free_trial:
|
|
from igny8_core.business.billing.models import AccountPaymentMethod
|
|
AccountPaymentMethod.objects.create(
|
|
account=account,
|
|
type=payment_method,
|
|
is_default=True,
|
|
is_enabled=True
|
|
)
|
|
|
|
# ... rest of creation logic ...
|
|
```
|
|
|
|
#### 3.5 Frontend: Add Billing Form Step
|
|
```typescript
|
|
// File: frontend/src/components/auth/SignUpForm.tsx
|
|
|
|
interface BillingFormData {
|
|
billing_email: string;
|
|
billing_address: string;
|
|
billing_city: string;
|
|
billing_country: string;
|
|
payment_method: string;
|
|
}
|
|
|
|
const [step, setStep] = useState<'signup' | 'billing'>('signup');
|
|
const [billingData, setBillingData] = useState<BillingFormData>({
|
|
billing_email: '',
|
|
billing_address: '',
|
|
billing_city: '',
|
|
billing_country: '',
|
|
payment_method: 'stripe'
|
|
});
|
|
|
|
// After initial form submission
|
|
const handleSignupSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// If paid plan, show billing form
|
|
if (planSlug && planSlug !== 'free') {
|
|
setStep('billing');
|
|
} else {
|
|
// Free trial, submit directly
|
|
await submitRegistration();
|
|
}
|
|
};
|
|
|
|
// Billing form submission
|
|
const handleBillingSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
await submitRegistration();
|
|
};
|
|
|
|
const submitRegistration = async () => {
|
|
const payload = {
|
|
email,
|
|
password,
|
|
password_confirm: passwordConfirm,
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
plan_slug: planSlug,
|
|
...(step === 'billing' ? billingData : {})
|
|
};
|
|
|
|
await register(payload);
|
|
};
|
|
|
|
// Render billing form
|
|
{step === 'billing' && (
|
|
<div className="billing-form">
|
|
<h3>Billing Information</h3>
|
|
|
|
<Input
|
|
label="Billing Email"
|
|
type="email"
|
|
value={billingData.billing_email}
|
|
onChange={(e) => setBillingData({
|
|
...billingData,
|
|
billing_email: e.target.value
|
|
})}
|
|
required
|
|
/>
|
|
|
|
<Input
|
|
label="Address"
|
|
value={billingData.billing_address}
|
|
onChange={(e) => setBillingData({
|
|
...billingData,
|
|
billing_address: e.target.value
|
|
})}
|
|
required
|
|
/>
|
|
|
|
<CountrySelect
|
|
label="Country"
|
|
value={billingData.billing_country}
|
|
onChange={(country) => setBillingData({
|
|
...billingData,
|
|
billing_country: country
|
|
})}
|
|
/>
|
|
|
|
<PaymentMethodSelect
|
|
country={billingData.billing_country}
|
|
value={billingData.payment_method}
|
|
onChange={(method) => setBillingData({
|
|
...billingData,
|
|
payment_method: method
|
|
})}
|
|
/>
|
|
|
|
<Button onClick={handleBillingSubmit}>
|
|
Complete Signup
|
|
</Button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
#### 3.6 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 <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
|
|
```bash
|
|
# 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
|
|
```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
|
|
- payment_method # REMOVE (use property)
|
|
|
|
# Invoice
|
|
- 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
|
|
- transaction_reference # REMOVE (duplicate of manual_reference)
|
|
|
|
# Site
|
|
! industry = FK(Industry, null=False) # MAKE REQUIRED
|
|
|
|
# Account (no changes, keep payment_method for backward compatibility)
|
|
```
|
|
|
|
### API Endpoints Created
|
|
```
|
|
GET /v1/billing/payment-methods/?country=PK
|
|
POST /v1/billing/payments/confirm/
|
|
POST /v1/auth/register/ (enhanced with billing fields)
|
|
```
|
|
|
|
### 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)
|
|
```
|
|
|