Revert "sadasd"

This reverts commit 9f85ce4f52.
This commit is contained in:
alorig
2025-12-09 00:26:01 +05:00
parent 9f85ce4f52
commit 92d16c76a7
11 changed files with 198 additions and 774 deletions

View File

@@ -30,18 +30,12 @@
6. **Country-Specific Logic Missing** - Pakistan payment methods not filtered 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 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` 8. **Broken Paid Signup Flow** - Imports non-existent `billing.Subscription`
9. **Credits Not Single Source** - Account.credits, Plan.included_credits not synchronized
10. **Invoice PDF Missing** - No pdf_file field, no proper PDF generation/download
11. **No Billing Settings Section** - Collect minimal data in signup, need account settings for full billing info
**Complexity Issues:** **Complexity Issues:**
- Payment method filtering by region not properly implemented - 4 global payment methods + 1 country-specific = unnecessary complexity
- Payment method config table exists but not used in signup flow - Payment method config table exists but not used in signup flow
- Manual payment instructions not shown to users - Manual payment instructions not shown to users
- No clear workflow for payment confirmation after signup - 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 ### Target Architecture
@@ -72,11 +66,9 @@ Payment (many) │◄── Links to Invoice
``` ```
**Simplified Payment Methods:** **Simplified Payment Methods:**
- **Global (2):** Stripe, PayPal (available worldwide) - **Global (3):** Stripe, PayPal, Bank Transfer
- **Regional (2):** - **Country-Specific (1):** Local Wallet (Pakistan only)
- Bank Transfer (UK, USA, Canada, Europe only) - **Total:** 4 payment methods (removed unnecessary complexity)
- Local Wallet (Pakistan only - JazzCash/Easypaisa)
- **Total:** 4 payment methods maximum per region
--- ---
@@ -1869,7 +1861,7 @@ docker exec igny8_backend python manage.py shell
```python ```python
from igny8_core.business.billing.models import PaymentMethodConfig from igny8_core.business.billing.models import PaymentMethodConfig
# Global methods (available worldwide) # Global methods (available everywhere)
PaymentMethodConfig.objects.get_or_create( PaymentMethodConfig.objects.get_or_create(
country_code='*', country_code='*',
payment_method='stripe', payment_method='stripe',
@@ -1890,28 +1882,25 @@ PaymentMethodConfig.objects.get_or_create(
} }
) )
# UK, USA, Canada, Europe - Bank Transfer PaymentMethodConfig.objects.get_or_create(
for country in ['GB', 'US', 'CA', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'IE', 'SE', 'NO', 'DK', 'FI']: country_code='*',
PaymentMethodConfig.objects.get_or_create( payment_method='bank_transfer',
country_code=country, defaults={
payment_method='bank_transfer', 'is_enabled': True,
defaults={ 'display_name': 'Bank Transfer',
'is_enabled': True, 'instructions': '''
'display_name': 'Bank Transfer', Bank Name: ABC Bank
'instructions': ''' Account Name: IGNY8 Inc
Bank Name: ABC Bank Account Number: 123456789
Account Name: IGNY8 Inc SWIFT: ABCPKKA
Account Number: 123456789
SWIFT/BIC: ABCPKKA
IBAN: GB00ABCD12345678901234
Please transfer the exact invoice amount and include your invoice number as reference. Please transfer the exact invoice amount and keep the transaction reference.
''', ''',
'sort_order': 3 'sort_order': 3
} }
) )
# Pakistan - Local Wallet (JazzCash/Easypaisa) # Pakistan-specific
PaymentMethodConfig.objects.get_or_create( PaymentMethodConfig.objects.get_or_create(
country_code='PK', country_code='PK',
payment_method='local_wallet', payment_method='local_wallet',
@@ -1923,21 +1912,15 @@ PaymentMethodConfig.objects.get_or_create(
'instructions': ''' 'instructions': '''
Send payment to: Send payment to:
JazzCash: 03001234567 JazzCash: 03001234567
Account Title: IGNY8 Account Name: IGNY8
After payment: Please keep the transaction ID and confirm payment after sending.
1. Note the Transaction ID
2. Submit confirmation below with Transaction ID
3. Upload screenshot (optional)
''', ''',
'sort_order': 4 'sort_order': 4
} }
) )
print("Payment method configurations created!") 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 #### 3.2 Create Payment Methods API Endpoint
@@ -2013,13 +1996,171 @@ def confirm_payment(self, request):
) )
``` ```
---
**NOTE:** RegisterSerializer already handles signup correctly with existing billing fields in Account model. No changes needed. #### 3.4 Update RegisterSerializer for Billing Fields
```python
# File: backend/igny8_core/auth/serializers.py
--- class RegisterSerializer(serializers.ModelSerializer):
# ... existing fields ...
#### 3.4 Admin Payment Approval Interface # 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 ```python
# File: backend/igny8_core/business/billing/admin.py # File: backend/igny8_core/business/billing/admin.py
@@ -2287,53 +2428,28 @@ Create sites (up to 3) → Add sectors → Start using
### Model Changes Summary ### Model Changes Summary
```python ```python
# Subscription # Subscription
+ plan = FK(Plan) # NEW - for historical tracking + plan = FK(Plan) # NEW
- payment_method # REMOVE (use property from AccountPaymentMethod) - payment_method # REMOVE (use property)
# Invoice # Invoice
+ pdf_file = FileField # NEW - generated PDF storage
- billing_period_start # REMOVE (use property from subscription) - billing_period_start # REMOVE (use property from subscription)
- billing_period_end # REMOVE (use property from subscription) - billing_period_end # REMOVE (use property from subscription)
- billing_email # REMOVE (use property from metadata/account) - billing_email # REMOVE (use property from metadata/account)
# Payment # Payment
+ is_current_period = BooleanField # NEW - flag for current billing period
- transaction_reference # REMOVE (duplicate of manual_reference) - 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 # Site
! industry = FK(Industry, null=False) # MAKE REQUIRED ! industry = FK(Industry, null=False) # MAKE REQUIRED
# Plan # Account (no changes, keep payment_method for backward compatibility)
! included_credits - SOURCE OF TRUTH for all credit amounts
``` ```
### API Endpoints - New & Updated ### API Endpoints Created
**New Endpoints:**
``` ```
GET /v1/billing/payment-methods/?country={code} # Filter methods by region GET /v1/billing/payment-methods/?country=PK
POST /v1/billing/payments/confirm/ # Manual payment confirmation POST /v1/billing/payments/confirm/
PATCH /v1/auth/account/billing # Update full billing details POST /v1/auth/register/ (enhanced with billing fields)
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 ### Database Queries for Verification
@@ -2370,589 +2486,3 @@ HAVING a.credits != COALESCE(SUM(ct.amount), 0);
-- Expected: 0 rows (all match) -- Expected: 0 rows (all match)
``` ```
---
**NOTE:** Account model already has all billing fields (billing_email, billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country). No new fields needed.
---
**NOTE:** Invoices already provide complete payment history. The InvoiceViewSet.list() endpoint returns all invoices with payment status. No separate payment history needed.
---
#### 3.5 Invoice PDF Generation & Download
```python
# File: backend/igny8_core/business/billing/models.py
class Invoice(AccountBaseModel):
# ... existing fields ...
# ADD NEW FIELD
pdf_file = models.FileField(
upload_to='invoices/pdf/%Y/%m/',
null=True,
blank=True,
help_text='Generated PDF invoice file'
)
def generate_pdf(self):
"""Generate PDF and save to file field"""
from .services.invoice_service import InvoiceService
pdf_bytes = InvoiceService.generate_pdf(self)
# Save to file field
from django.core.files.base import ContentFile
filename = f'invoice-{self.invoice_number}.pdf'
self.pdf_file.save(filename, ContentFile(pdf_bytes), save=True)
return self.pdf_file.url
def save(self, *args, **kwargs):
"""Auto-generate PDF after invoice is saved"""
super().save(*args, **kwargs)
# Generate PDF for paid/draft invoices
if self.status in ['paid', 'draft', 'sent'] and not self.pdf_file:
self.generate_pdf()
```
**Enhanced PDF Service:**
```python
# File: backend/igny8_core/business/billing/services/invoice_service.py
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
from reportlab.lib.units import inch
from io import BytesIO
from django.conf import settings
import os
class InvoiceService:
@staticmethod
def generate_pdf(invoice):
"""Generate professional PDF invoice"""
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4,
leftMargin=0.75*inch, rightMargin=0.75*inch,
topMargin=0.75*inch, bottomMargin=0.75*inch)
elements = []
styles = getSampleStyleSheet()
# Company Header
if os.path.exists(os.path.join(settings.STATIC_ROOT, 'logo.png')):
logo = Image(os.path.join(settings.STATIC_ROOT, 'logo.png'), width=2*inch, height=0.5*inch)
elements.append(logo)
elements.append(Spacer(1, 0.3*inch))
# Invoice Title
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#1a202c'),
spaceAfter=30,
)
elements.append(Paragraph('INVOICE', title_style))
# Invoice Info Table
info_data = [
['Invoice Number:', invoice.invoice_number],
['Invoice Date:', invoice.invoice_date.strftime('%B %d, %Y')],
['Due Date:', invoice.due_date.strftime('%B %d, %Y')],
['Status:', invoice.status.upper()],
]
if invoice.paid_at:
info_data.append(['Paid On:', invoice.paid_at.strftime('%B %d, %Y')])
info_table = Table(info_data, colWidths=[2*inch, 3*inch])
info_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
elements.append(info_table)
elements.append(Spacer(1, 0.4*inch))
# Billing Info
billing_style = ParagraphStyle(
'BillingStyle',
parent=styles['Normal'],
fontSize=10,
leading=14,
)
bill_to = f"<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):**
```python
# File: backend/igny8_core/business/billing/views.py
@action(detail=True, methods=['get'])
def download_pdf(self, request, pk=None):
"""Download invoice PDF - serves from file field if exists"""
try:
invoice = self.get_queryset().get(pk=pk)
# Return existing PDF file if available
if invoice.pdf_file:
response = HttpResponse(invoice.pdf_file.read(), content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"'
return response
# Generate on-the-fly if not exists
pdf_bytes = InvoiceService.generate_pdf(invoice)
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"'
return response
except Invoice.DoesNotExist:
return error_response(error='Invoice not found', status_code=404, request=request)
```
**Migration for pdf_file field:**
```bash
python manage.py makemigrations --name add_invoice_pdf_field
```
```python
# Migration file
class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name='invoice',
name='pdf_file',
field=models.FileField(
upload_to='invoices/pdf/%Y/%m/',
null=True,
blank=True,
help_text='Generated PDF invoice file'
),
),
]
```
---
#### 3.9 Credits Single Source of Truth
```python
# File: backend/igny8_core/auth/models.py
class Plan(models.Model):
"""
Plan model - SOURCE OF TRUTH for credit amounts
"""
included_credits = models.IntegerField(
default=0,
help_text='Monthly credits included in plan (source of truth)'
)
# ... other fields ...
class Account(SoftDeletableModel):
"""
Account model - STORES CURRENT BALANCE only
Credits populated from Plan.included_credits
"""
plan = models.ForeignKey(Plan, on_delete=models.PROTECT)
credits = models.IntegerField(
default=0,
help_text='Current credit balance (populated from plan.included_credits)'
)
@property
def monthly_allowance(self):
"""Monthly allowance always from plan"""
return self.plan.included_credits
@property
def total_credits_this_period(self):
"""Total credits for current billing period"""
return self.plan.included_credits
def reset_monthly_credits(self):
"""Reset credits at billing cycle - ALWAYS from plan"""
self.credits = self.plan.included_credits
self.save(update_fields=['credits'])
# Log the reset
CreditTransaction.objects.create(
account=self,
transaction_type='monthly_reset',
amount=self.plan.included_credits,
balance_after=self.credits,
description=f'Monthly credit reset - {self.plan.name} plan',
metadata={'plan_id': self.plan.id, 'included_credits': self.plan.included_credits}
)
class Subscription(models.Model):
"""
Subscription - NO credit fields, uses account.credits
"""
account = models.OneToOneField(Account, on_delete=models.CASCADE)
plan = models.ForeignKey(Plan, on_delete=models.PROTECT) # For historical tracking
# NO credits field here
@property
def remaining_credits(self):
"""Remaining credits from account"""
return self.account.credits
@property
def monthly_allowance(self):
"""Monthly allowance from plan"""
return self.plan.included_credits
class Invoice(AccountBaseModel):
"""
Invoice - NO credit fields, reference from subscription.plan
"""
subscription = models.ForeignKey(Subscription, on_delete=models.PROTECT, null=True)
# NO included_credits field
@property
def plan_credits(self):
"""Credits from subscription's plan (historical snapshot)"""
return self.subscription.plan.included_credits if self.subscription else 0
```
**Credit Flow Documentation:**
```
SINGLE SOURCE OF TRUTH: Plan.included_credits
Plan.included_credits (1000)
├─→ Account.credits (populated on signup/renewal)
│ └─→ Updated by CreditService.add_credits() / deduct_credits()
├─→ Account.monthly_allowance (property, reads from plan)
└─→ Subscription.plan.included_credits (historical record)
└─→ Invoice.plan_credits (property, from subscription.plan)
RULES:
1. Plan.included_credits = NEVER changes unless admin updates plan
2. Account.credits = CURRENT balance (can be spent/added to)
3. Account.monthly_allowance = ALWAYS reads from plan (property)
4. Monthly reset: Account.credits = Plan.included_credits
5. Invoice references: Use subscription.plan.included_credits (snapshot)
```
**CreditService enforcement:**
```python
# File: backend/igny8_core/business/billing/services/credit_service.py
class CreditService:
@staticmethod
def add_credits(account, amount, description, transaction_type='manual'):
"""Add credits - updates ONLY account.credits"""
with transaction.atomic():
account.credits += amount
account.save(update_fields=['credits'])
CreditTransaction.objects.create(
account=account,
transaction_type=transaction_type,
amount=amount,
balance_after=account.credits,
description=description
)
@staticmethod
def reset_monthly_credits(account):
"""Reset to plan amount - SINGLE SOURCE"""
new_balance = account.plan.included_credits
with transaction.atomic():
account.credits = new_balance
account.save(update_fields=['credits'])
CreditTransaction.objects.create(
account=account,
transaction_type='monthly_reset',
amount=new_balance,
balance_after=new_balance,
description=f'Monthly reset - {account.plan.name} plan',
metadata={
'plan_id': account.plan.id,
'plan_credits': account.plan.included_credits
}
)
```
```python
# File: backend/igny8_core/business/billing/models.py
class Payment(AccountBaseModel):
"""
Payment model - handles both one-time and recurring payments
STRATEGY:
- external_payment_id: Current active payment reference (Stripe sub ID, transaction ID)
- manual_reference: User-provided reference for manual payments
- invoice: Links payment to specific billing period
- Multiple payments can exist for same subscription (historical)
"""
invoice = models.ForeignKey(
Invoice,
on_delete=models.PROTECT,
related_name='payments',
help_text='Invoice this payment is for (links to billing period)'
)
external_payment_id = models.CharField(
max_length=255,
blank=True,
help_text='External payment reference (Stripe charge ID, PayPal txn ID, etc.)'
)
manual_reference = models.CharField(
max_length=255,
blank=True,
help_text='User-provided reference for manual payments (bank transfer ID, etc.)'
)
is_current_period = models.BooleanField(
default=False,
help_text='Is this payment for the current active billing period?'
)
@classmethod
def get_current_period_payment(cls, account):
"""Get active payment for current billing period"""
return cls.objects.filter(
account=account,
is_current_period=True,
status='succeeded'
).first()
@classmethod
def get_payment_history(cls, account):
"""Get all historical payments"""
return cls.objects.filter(
account=account
).select_related('invoice').order_by('-created_at')
```
**Recurring Payment Workflow:**
```python
# File: backend/igny8_core/business/billing/services/subscription_service.py
class SubscriptionService:
@staticmethod
def renew_subscription(subscription):
"""
Renew subscription - creates NEW invoice and payment
Previous payments remain in history
"""
with transaction.atomic():
# Mark old payment as historical
Payment.objects.filter(
account=subscription.account,
is_current_period=True
).update(is_current_period=False)
# Create new invoice for new period
new_invoice = Invoice.objects.create(
account=subscription.account,
subscription=subscription,
invoice_number=f'INV-{timezone.now().strftime("%Y%m%d")}-{subscription.account.id}',
total=subscription.plan.price,
subtotal=subscription.plan.price,
status='pending'
)
# Update subscription period
subscription.current_period_start = timezone.now()
subscription.current_period_end = timezone.now() + timedelta(days=30)
subscription.save()
# If auto-pay enabled, create payment
if subscription.account.payment_method in ['stripe', 'paypal']:
payment = Payment.objects.create(
account=subscription.account,
invoice=new_invoice,
amount=new_invoice.total,
payment_method=subscription.account.payment_method,
status='processing',
is_current_period=True
)
# Process payment via gateway
# ... payment processing logic ...
return new_invoice
```
**Payment History View:**
```python
# File: backend/igny8_core/business/billing/views.py
@action(detail=False, methods=['get'], url_path='payment-history')
def payment_history(self, request):
"""Get complete payment history with invoice details"""
payments = Payment.objects.filter(
account=request.account
).select_related('invoice', 'invoice__subscription').order_by('-created_at')
results = []
for payment in payments:
results.append({
'id': payment.id,
'invoice_number': payment.invoice.invoice_number,
'amount': str(payment.amount),
'status': payment.status,
'payment_method': payment.payment_method,
'reference': payment.external_payment_id or payment.manual_reference,
'is_current': payment.is_current_period,
'billing_period': {
'start': payment.invoice.subscription.current_period_start.isoformat(),
'end': payment.invoice.subscription.current_period_end.isoformat()
} if payment.invoice.subscription else None,
'paid_at': payment.processed_at.isoformat() if payment.processed_at else None,
})
return success_response(data={'payments': results}, request=request)
```
**Frontend Payment History:**
```typescript
// File: frontend/src/pages/BillingHistory.tsx
export const PaymentHistory = () => {
const [payments, setPayments] = useState([]);
return (
<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>
);
};
```

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 17:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0009_add_plan_annual_discount_and_featured'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='plan',
field=models.ForeignKey(blank=True, help_text='Plan for this subscription (historical tracking)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='igny8_core_auth.plan'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 17:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0010_add_subscription_plan_field'),
]
operations = [
migrations.AlterField(
model_name='site',
name='industry',
field=models.ForeignKey(help_text='Industry this site belongs to (required)', on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='igny8_core_auth.industry'),
),
]

View File

@@ -230,14 +230,6 @@ class Subscription(models.Model):
] ]
account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id') account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id')
plan = models.ForeignKey(
'igny8_core_auth.Plan',
on_delete=models.PROTECT,
related_name='subscriptions',
null=True,
blank=True,
help_text='Plan for this subscription (historical tracking)'
)
stripe_subscription_id = models.CharField( stripe_subscription_id = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
@@ -294,7 +286,9 @@ class Site(SoftDeletableModel, AccountBaseModel):
'igny8_core_auth.Industry', 'igny8_core_auth.Industry',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='sites', related_name='sites',
help_text="Industry this site belongs to (required)" null=True,
blank=True,
help_text="Industry this site belongs to"
) )
is_active = models.BooleanField(default=True, db_index=True) is_active = models.BooleanField(default=True, db_index=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')

View File

@@ -287,7 +287,7 @@ class RegisterSerializer(serializers.Serializer):
def create(self, validated_data): def create(self, validated_data):
from django.db import transaction from django.db import transaction
from igny8_core.business.billing.models import CreditTransaction from igny8_core.business.billing.models import CreditTransaction
from igny8_core.auth.models import Subscription from igny8_core.business.billing.models import Subscription
from igny8_core.business.billing.models import AccountPaymentMethod from igny8_core.business.billing.models import AccountPaymentMethod
from igny8_core.business.billing.services.invoice_service import InvoiceService from igny8_core.business.billing.services.invoice_service import InvoiceService
from django.utils import timezone from django.utils import timezone

View File

@@ -215,12 +215,6 @@ class Invoice(AccountBaseModel):
# Metadata # Metadata
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
pdf_file = models.FileField(
upload_to='invoices/pdf/%Y/%m/',
null=True,
blank=True,
help_text='Generated PDF invoice file'
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 17:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0006_accountpaymentmethod'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='pdf_file',
field=models.FileField(blank=True, help_text='Generated PDF invoice file', null=True, upload_to='invoices/pdf/%Y/%m/'),
),
]

View File

@@ -39,26 +39,15 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
// Validate account + plan whenever auth/user changes // Validate account + plan whenever auth/user changes
useEffect(() => { useEffect(() => {
console.log('[ProtectedRoute] Auth state changed:', {
isAuthenticated,
hasUser: !!user,
hasAccount: !!user?.account,
pathname: location.pathname
});
if (!isAuthenticated) { if (!isAuthenticated) {
console.log('[ProtectedRoute] Not authenticated, will redirect to signin');
return; return;
} }
if (!user?.account) { if (!user?.account) {
console.error('[ProtectedRoute] User has no account, logging out');
setErrorMessage('This user is not linked to an account. Please contact support.'); setErrorMessage('This user is not linked to an account. Please contact support.');
logout(); logout();
return; return;
} }
console.log('[ProtectedRoute] Auth validation passed');
}, [isAuthenticated, user, logout]); }, [isAuthenticated, user, logout]);
// Immediate check on mount: if loading is true, reset it immediately // Immediate check on mount: if loading is true, reset it immediately
@@ -125,7 +114,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
// Redirect to signin if not authenticated // Redirect to signin if not authenticated
if (!isAuthenticated) { if (!isAuthenticated) {
console.log('[ProtectedRoute] Redirecting to /signin - not authenticated');
return <Navigate to="/signin" state={{ from: location }} replace />; return <Navigate to="/signin" state={{ from: location }} replace />;
} }

View File

@@ -28,11 +28,6 @@ export default function SignInForm() {
try { try {
await login(email, password); await login(email, password);
// CRITICAL: Wait for auth state to persist to localStorage before navigating
// Increased to 500ms to ensure Zustand persist middleware completes
await new Promise(resolve => setTimeout(resolve, 500));
// Redirect to the page user was trying to access, or home // Redirect to the page user was trying to access, or home
const from = (location.state as any)?.from?.pathname || "/"; const from = (location.state as any)?.from?.pathname || "/";
navigate(from, { replace: true }); navigate(from, { replace: true });

View File

@@ -85,11 +85,9 @@ export default function SignUpForm({ planDetails: planDetailsProp, planLoading:
} }
try { try {
console.log('[SignUp] Starting registration...');
// Generate username from email if not provided // Generate username from email if not provided
const username = formData.username || formData.email.split("@")[0]; const username = formData.username || formData.email.split("@")[0];
console.log('[SignUp] Calling register API...');
const user = await register({ const user = await register({
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
@@ -100,29 +98,13 @@ export default function SignUpForm({ planDetails: planDetailsProp, planLoading:
plan_slug: planSlug || undefined, plan_slug: planSlug || undefined,
}); });
console.log('[SignUp] Registration successful, user:', user);
console.log('[SignUp] Auth state before delay:', useAuthStore.getState());
// CRITICAL: Wait for auth state to persist to localStorage before navigating
// Increased to 500ms to ensure Zustand persist middleware completes
await new Promise(resolve => setTimeout(resolve, 500));
// Verify auth state is persisted
const persistedState = localStorage.getItem('auth-storage');
console.log('[SignUp] Persisted auth state:', persistedState);
console.log('[SignUp] Auth state after delay:', useAuthStore.getState());
const status = user?.account?.status; const status = user?.account?.status;
console.log('[SignUp] Account status:', status);
if (status === "pending_payment") { if (status === "pending_payment") {
console.log('[SignUp] Navigating to /account/plans');
navigate("/account/plans", { replace: true }); navigate("/account/plans", { replace: true });
} else { } else {
console.log('[SignUp] Navigating to /sites');
navigate("/sites", { replace: true }); navigate("/sites", { replace: true });
} }
} catch (err: any) { } catch (err: any) {
console.error('[SignUp] Registration error:', err);
setError(err.message || "Registration failed. Please try again."); setError(err.message || "Registration failed. Please try again.");
} }
}; };

View File

@@ -35,7 +35,6 @@ interface AuthState {
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
loading: boolean; loading: boolean;
_hasHydrated: boolean; // Track if persist has completed rehydration
// Actions // Actions
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
@@ -45,7 +44,6 @@ interface AuthState {
setToken: (token: string | null) => void; setToken: (token: string | null) => void;
refreshToken: () => Promise<void>; refreshToken: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
setHasHydrated: (hasHydrated: boolean) => void;
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -55,7 +53,6 @@ export const useAuthStore = create<AuthState>()(
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
loading: false, // Always start with loading false - will be set true only during login/register loading: false, // Always start with loading false - will be set true only during login/register
_hasHydrated: false, // Will be set to true after persist rehydration completes
login: async (email, password) => { login: async (email, password) => {
set({ loading: true }); set({ loading: true });