@@ -30,18 +30,12 @@
|
||||
6. **Country-Specific Logic Missing** - Pakistan payment methods not filtered
|
||||
7. **Site Industry Not Required** - Can create sites without industry, then can't add sectors
|
||||
8. **Broken Paid Signup Flow** - Imports non-existent `billing.Subscription`
|
||||
9. **Credits Not Single Source** - Account.credits, Plan.included_credits not synchronized
|
||||
10. **Invoice PDF Missing** - No pdf_file field, no proper PDF generation/download
|
||||
11. **No Billing Settings Section** - Collect minimal data in signup, need account settings for full billing info
|
||||
|
||||
**Complexity Issues:**
|
||||
- Payment method filtering by region not properly implemented
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -72,11 +66,9 @@ Payment (many) │◄── Links to Invoice
|
||||
```
|
||||
|
||||
**Simplified Payment Methods:**
|
||||
- **Global (2):** Stripe, PayPal (available worldwide)
|
||||
- **Regional (2):**
|
||||
- Bank Transfer (UK, USA, Canada, Europe only)
|
||||
- Local Wallet (Pakistan only - JazzCash/Easypaisa)
|
||||
- **Total:** 4 payment methods maximum per region
|
||||
- **Global (3):** Stripe, PayPal, Bank Transfer
|
||||
- **Country-Specific (1):** Local Wallet (Pakistan only)
|
||||
- **Total:** 4 payment methods (removed unnecessary complexity)
|
||||
|
||||
---
|
||||
|
||||
@@ -1869,7 +1861,7 @@ docker exec igny8_backend python manage.py shell
|
||||
```python
|
||||
from igny8_core.business.billing.models import PaymentMethodConfig
|
||||
|
||||
# Global methods (available worldwide)
|
||||
# Global methods (available everywhere)
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code='*',
|
||||
payment_method='stripe',
|
||||
@@ -1890,28 +1882,25 @@ PaymentMethodConfig.objects.get_or_create(
|
||||
}
|
||||
)
|
||||
|
||||
# UK, USA, Canada, Europe - Bank Transfer
|
||||
for country in ['GB', 'US', 'CA', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'IE', 'SE', 'NO', 'DK', 'FI']:
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code=country,
|
||||
payment_method='bank_transfer',
|
||||
defaults={
|
||||
'is_enabled': True,
|
||||
'display_name': 'Bank Transfer',
|
||||
'instructions': '''
|
||||
Bank Name: ABC Bank
|
||||
Account Name: IGNY8 Inc
|
||||
Account Number: 123456789
|
||||
SWIFT/BIC: ABCPKKA
|
||||
IBAN: GB00ABCD12345678901234
|
||||
|
||||
Please transfer the exact invoice amount and include your invoice number as reference.
|
||||
''',
|
||||
'sort_order': 3
|
||||
}
|
||||
)
|
||||
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 - Local Wallet (JazzCash/Easypaisa)
|
||||
# Pakistan-specific
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code='PK',
|
||||
payment_method='local_wallet',
|
||||
@@ -1923,21 +1912,15 @@ PaymentMethodConfig.objects.get_or_create(
|
||||
'instructions': '''
|
||||
Send payment to:
|
||||
JazzCash: 03001234567
|
||||
Account Title: IGNY8
|
||||
Account Name: IGNY8
|
||||
|
||||
After payment:
|
||||
1. Note the Transaction ID
|
||||
2. Submit confirmation below with Transaction ID
|
||||
3. Upload screenshot (optional)
|
||||
Please keep the transaction ID and confirm payment after sending.
|
||||
''',
|
||||
'sort_order': 4
|
||||
}
|
||||
)
|
||||
|
||||
print("Payment method configurations created!")
|
||||
print("- Global: Stripe, PayPal")
|
||||
print("- UK/USA/Canada/Europe: + Bank Transfer")
|
||||
print("- Pakistan: + JazzCash/Easypaisa")
|
||||
```
|
||||
|
||||
#### 3.2 Create Payment Methods API Endpoint
|
||||
@@ -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 ...
|
||||
|
||||
# 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.4 Admin Payment Approval Interface
|
||||
#### 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
|
||||
|
||||
@@ -2287,53 +2428,28 @@ Create sites (up to 3) → Add sectors → Start using
|
||||
### Model Changes Summary
|
||||
```python
|
||||
# Subscription
|
||||
+ plan = FK(Plan) # NEW - for historical tracking
|
||||
- payment_method # REMOVE (use property from AccountPaymentMethod)
|
||||
+ plan = FK(Plan) # NEW
|
||||
- payment_method # REMOVE (use property)
|
||||
|
||||
# Invoice
|
||||
+ pdf_file = FileField # NEW - generated PDF storage
|
||||
- billing_period_start # REMOVE (use property from subscription)
|
||||
- billing_period_end # REMOVE (use property from subscription)
|
||||
- billing_email # REMOVE (use property from metadata/account)
|
||||
|
||||
# Payment
|
||||
+ is_current_period = BooleanField # NEW - flag for current billing period
|
||||
- transaction_reference # REMOVE (duplicate of manual_reference)
|
||||
|
||||
# Account
|
||||
+ billing_company, tax_id, billing_phone, billing_state, billing_postal_code # NEW optional fields
|
||||
! credits - SINGLE SOURCE, populated from plan.included_credits
|
||||
|
||||
# Site
|
||||
! industry = FK(Industry, null=False) # MAKE REQUIRED
|
||||
|
||||
# Plan
|
||||
! included_credits - SOURCE OF TRUTH for all credit amounts
|
||||
# Account (no changes, keep payment_method for backward compatibility)
|
||||
```
|
||||
|
||||
### API Endpoints - New & Updated
|
||||
|
||||
**New Endpoints:**
|
||||
### API Endpoints Created
|
||||
```
|
||||
GET /v1/billing/payment-methods/?country={code} # Filter methods by region
|
||||
POST /v1/billing/payments/confirm/ # Manual payment confirmation
|
||||
PATCH /v1/auth/account/billing # Update full billing details
|
||||
GET /v1/billing/payment-history # Complete payment history
|
||||
```
|
||||
|
||||
**Existing Endpoints (Already Implemented):**
|
||||
```
|
||||
POST /v1/auth/register/ # ✅ EXISTS - need to enhance with minimal billing
|
||||
GET /v1/billing/invoices/{id}/download_pdf/ # ✅ EXISTS - need to serve from pdf_file field
|
||||
POST /v1/billing/confirm-bank-transfer # ✅ EXISTS - admin manual payment approval
|
||||
POST /v1/billing/payments/manual # ✅ EXISTS - user submits manual payment
|
||||
```
|
||||
|
||||
**Payment Method Regional Configuration:**
|
||||
```
|
||||
Global (all countries): Stripe, PayPal
|
||||
UK/USA/Canada/Europe: + Bank Transfer
|
||||
Pakistan: + JazzCash/Easypaisa (local_wallet)
|
||||
GET /v1/billing/payment-methods/?country=PK
|
||||
POST /v1/billing/payments/confirm/
|
||||
POST /v1/auth/register/ (enhanced with billing fields)
|
||||
```
|
||||
|
||||
### Database Queries for Verification
|
||||
@@ -2370,589 +2486,3 @@ HAVING a.credits != COALESCE(SUM(ct.amount), 0);
|
||||
-- Expected: 0 rows (all match)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**NOTE:** Account model already has all billing fields (billing_email, billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country). No new fields needed.
|
||||
|
||||
---
|
||||
|
||||
**NOTE:** Invoices already provide complete payment history. The InvoiceViewSet.list() endpoint returns all invoices with payment status. No separate payment history needed.
|
||||
|
||||
---
|
||||
|
||||
#### 3.5 Invoice PDF Generation & Download
|
||||
```python
|
||||
# File: backend/igny8_core/business/billing/models.py
|
||||
|
||||
class Invoice(AccountBaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# ADD NEW FIELD
|
||||
pdf_file = models.FileField(
|
||||
upload_to='invoices/pdf/%Y/%m/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Generated PDF invoice file'
|
||||
)
|
||||
|
||||
def generate_pdf(self):
|
||||
"""Generate PDF and save to file field"""
|
||||
from .services.invoice_service import InvoiceService
|
||||
|
||||
pdf_bytes = InvoiceService.generate_pdf(self)
|
||||
|
||||
# Save to file field
|
||||
from django.core.files.base import ContentFile
|
||||
filename = f'invoice-{self.invoice_number}.pdf'
|
||||
self.pdf_file.save(filename, ContentFile(pdf_bytes), save=True)
|
||||
|
||||
return self.pdf_file.url
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Auto-generate PDF after invoice is saved"""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Generate PDF for paid/draft invoices
|
||||
if self.status in ['paid', 'draft', 'sent'] and not self.pdf_file:
|
||||
self.generate_pdf()
|
||||
```
|
||||
|
||||
**Enhanced PDF Service:**
|
||||
```python
|
||||
# File: backend/igny8_core/business/billing/services/invoice_service.py
|
||||
|
||||
from reportlab.lib.pagesizes import letter, A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
|
||||
from reportlab.lib.units import inch
|
||||
from io import BytesIO
|
||||
from django.conf import settings
|
||||
import os
|
||||
|
||||
class InvoiceService:
|
||||
@staticmethod
|
||||
def generate_pdf(invoice):
|
||||
"""Generate professional PDF invoice"""
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=A4,
|
||||
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
||||
topMargin=0.75*inch, bottomMargin=0.75*inch)
|
||||
|
||||
elements = []
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Company Header
|
||||
if os.path.exists(os.path.join(settings.STATIC_ROOT, 'logo.png')):
|
||||
logo = Image(os.path.join(settings.STATIC_ROOT, 'logo.png'), width=2*inch, height=0.5*inch)
|
||||
elements.append(logo)
|
||||
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
|
||||
# Invoice Title
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=24,
|
||||
textColor=colors.HexColor('#1a202c'),
|
||||
spaceAfter=30,
|
||||
)
|
||||
elements.append(Paragraph('INVOICE', title_style))
|
||||
|
||||
# Invoice Info Table
|
||||
info_data = [
|
||||
['Invoice Number:', invoice.invoice_number],
|
||||
['Invoice Date:', invoice.invoice_date.strftime('%B %d, %Y')],
|
||||
['Due Date:', invoice.due_date.strftime('%B %d, %Y')],
|
||||
['Status:', invoice.status.upper()],
|
||||
]
|
||||
|
||||
if invoice.paid_at:
|
||||
info_data.append(['Paid On:', invoice.paid_at.strftime('%B %d, %Y')])
|
||||
|
||||
info_table = Table(info_data, colWidths=[2*inch, 3*inch])
|
||||
info_table.setStyle(TableStyle([
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
elements.append(info_table)
|
||||
elements.append(Spacer(1, 0.4*inch))
|
||||
|
||||
# Billing Info
|
||||
billing_style = ParagraphStyle(
|
||||
'BillingStyle',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
leading=14,
|
||||
)
|
||||
|
||||
bill_to = f"<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>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user