|
|
|
|
@@ -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
|
|
|
|
|
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 include your invoice number as reference.
|
|
|
|
|
''',
|
|
|
|
|
'sort_order': 3
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
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 ...
|
|
|
|
|
|
|
|
|
|
#### 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
|
|
|
|
|
# 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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|