bottleneckcrm/backend/invoices/seed.py

857 lines
30 KiB
Python

"""
Invoice seeding module for the CRM application.
This module provides the InvoiceSeeder class which handles seeding of all
invoice-related entities: Products, InvoiceTemplates, Invoices, Payments,
Estimates, and RecurringInvoices.
"""
import datetime
import random
from decimal import Decimal
from django.utils import timezone
class InvoiceSeeder:
"""Handles seeding of all invoice-related entities."""
# Invoice status distribution weights
INVOICE_STATUS_WEIGHTS = {
"Draft": 20,
"Sent": 15,
"Viewed": 10,
"Paid": 25,
"Partially_Paid": 10,
"Overdue": 15,
"Cancelled": 5,
}
# Estimate status distribution weights
ESTIMATE_STATUS_WEIGHTS = {
"Draft": 20,
"Sent": 15,
"Viewed": 15,
"Accepted": 25,
"Declined": 15,
"Expired": 10,
}
# Payment method distribution weights
PAYMENT_METHOD_WEIGHTS = {
"BANK_TRANSFER": 35,
"CREDIT_CARD": 30,
"CHECK": 15,
"PAYPAL": 10,
"CASH": 5,
"STRIPE": 4,
"OTHER": 1,
}
# Payment terms distribution weights
PAYMENT_TERMS_WEIGHTS = {
"NET_30": 45,
"NET_15": 20,
"NET_60": 15,
"DUE_ON_RECEIPT": 10,
"NET_45": 10,
}
# Recurring frequency distribution weights
RECURRING_FREQUENCY_WEIGHTS = {
"MONTHLY": 50,
"QUARTERLY": 20,
"YEARLY": 15,
"WEEKLY": 10,
"BIWEEKLY": 5,
}
# Product categories
PRODUCT_CATEGORIES = [
"Consulting Services",
"Software License",
"Implementation",
"Training",
"Support",
"Hardware",
"Subscription",
"Custom Development",
"Integration",
"Maintenance",
]
# Product templates with realistic pricing
PRODUCT_TEMPLATES = [
{
"name": "Consulting - Senior",
"category": "Consulting Services",
"price_range": (150, 350),
},
{
"name": "Consulting - Standard",
"category": "Consulting Services",
"price_range": (75, 150),
},
{
"name": "Implementation Services",
"category": "Implementation",
"price_range": (5000, 25000),
},
{
"name": "Training Session (Full Day)",
"category": "Training",
"price_range": (1500, 3500),
},
{
"name": "Training Session (Half Day)",
"category": "Training",
"price_range": (800, 1800),
},
{
"name": "Enterprise License",
"category": "Software License",
"price_range": (10000, 50000),
},
{
"name": "Professional License",
"category": "Software License",
"price_range": (1000, 5000),
},
{
"name": "Team License (per seat)",
"category": "Software License",
"price_range": (50, 200),
},
{
"name": "Annual Support Plan",
"category": "Support",
"price_range": (2000, 10000),
},
{
"name": "Monthly Subscription",
"category": "Subscription",
"price_range": (99, 499),
},
{"name": "Premium Support", "category": "Support", "price_range": (500, 2000)},
{
"name": "Custom Development (per hour)",
"category": "Custom Development",
"price_range": (100, 250),
},
{
"name": "API Integration",
"category": "Integration",
"price_range": (3000, 15000),
},
{"name": "Hardware Setup", "category": "Hardware", "price_range": (500, 2500)},
{
"name": "Maintenance (Monthly)",
"category": "Maintenance",
"price_range": (200, 1000),
},
]
# Invoice template configurations
TEMPLATE_CONFIGS = [
{
"name": "Professional Blue",
"primary_color": "#3B82F6",
"secondary_color": "#1E40AF",
"is_default": True,
},
{
"name": "Corporate Gray",
"primary_color": "#4B5563",
"secondary_color": "#1F2937",
"is_default": False,
},
{
"name": "Modern Green",
"primary_color": "#10B981",
"secondary_color": "#047857",
"is_default": False,
},
{
"name": "Classic Red",
"primary_color": "#EF4444",
"secondary_color": "#B91C1C",
"is_default": False,
},
]
def __init__(self, fake, stdout):
"""
Initialize the InvoiceSeeder.
Args:
fake: Faker instance from the main command
stdout: stdout for output messages
"""
self.fake = fake
self.stdout = stdout
self.stats = {
"products": 0,
"invoice_templates": 0,
"invoices": 0,
"invoice_line_items": 0,
"payments": 0,
"estimates": 0,
"estimate_line_items": 0,
"recurring_invoices": 0,
"recurring_invoice_line_items": 0,
}
def _weighted_choice(self, weights_dict):
"""Select a random key based on weights."""
items = list(weights_dict.keys())
weights = list(weights_dict.values())
return random.choices(items, weights=weights, k=1)[0]
def _generate_invoice_date(self):
"""Generate realistic invoice dates weighted toward recent months."""
days_ago_weights = {
(0, 30): 35, # Last month: 35%
(31, 60): 25, # 1-2 months ago: 25%
(61, 90): 20, # 2-3 months ago: 20%
(91, 120): 10, # 3-4 months ago: 10%
(121, 150): 6, # 4-5 months ago: 6%
(151, 180): 4, # 5-6 months ago: 4%
}
range_choice = self._weighted_choice(days_ago_weights)
days_ago = random.randint(range_choice[0], range_choice[1])
return timezone.now().date() - datetime.timedelta(days=days_ago)
def _add_invoice_assignments(self, instance, profiles, teams):
"""Add M2M assignments to invoice-related entities (no tags)."""
if profiles:
num_assigned = random.randint(1, min(2, len(profiles)))
instance.assigned_to.add(*random.sample(profiles, num_assigned))
if teams and random.random() > 0.3:
instance.teams.add(random.choice(teams))
def create_products(self, org, count):
"""
Create product catalog items for the organization.
Args:
org: Organization instance
count: Number of products to create
Returns:
List of created Product instances
"""
from invoices.models import Product
products = []
# Use product templates first
templates_to_use = random.sample(
self.PRODUCT_TEMPLATES, min(count, len(self.PRODUCT_TEMPLATES))
)
for i, template in enumerate(templates_to_use):
product = Product.objects.create(
name=template["name"],
description=self.fake.paragraph(nb_sentences=2),
sku=f"SKU-{org.id.hex[:4].upper()}-{i + 1:04d}",
price=Decimal(str(random.randint(*template["price_range"]))),
currency=org.default_currency,
category=template["category"],
is_active=True,
org=org,
)
products.append(product)
self.stats["products"] += 1
# Add extra generic products if needed
remaining = count - len(templates_to_use)
for i in range(remaining):
category = random.choice(self.PRODUCT_CATEGORIES)
product = Product.objects.create(
name=f"{category} - {self.fake.word().title()}",
description=self.fake.paragraph(nb_sentences=2),
sku=f"SKU-{org.id.hex[:4].upper()}-{len(templates_to_use) + i + 1:04d}",
price=Decimal(str(random.randint(100, 10000))),
currency=org.default_currency,
category=category,
is_active=random.random() > 0.1, # 90% active
org=org,
)
products.append(product)
self.stats["products"] += 1
self.stdout.write(f" Created {len(products)} products")
return products
def create_invoice_templates(self, org, count):
"""
Create invoice PDF templates for the organization.
Args:
org: Organization instance
count: Number of templates to create
Returns:
List of created InvoiceTemplate instances
"""
from invoices.models import InvoiceTemplate
templates = []
configs_to_use = self.TEMPLATE_CONFIGS[: min(count, len(self.TEMPLATE_CONFIGS))]
for config in configs_to_use:
template = InvoiceTemplate.objects.create(
name=config["name"],
primary_color=config["primary_color"],
secondary_color=config["secondary_color"],
default_notes="Thank you for your business!",
default_terms="Payment is due within the specified terms. Late payments may incur additional fees.",
footer_text=f"Questions? Contact us at {org.email if hasattr(org, 'email') and org.email else 'billing@company.com'}",
is_default=config["is_default"],
org=org,
)
templates.append(template)
self.stats["invoice_templates"] += 1
self.stdout.write(f" Created {len(templates)} invoice templates")
return templates
def create_invoices(
self,
org,
profiles,
teams,
products,
templates,
accounts,
contacts,
opportunities,
count,
):
"""
Create invoices with line items.
Args:
org: Organization instance
profiles: List of Profile instances
teams: List of Teams instances
products: List of Product instances
templates: List of InvoiceTemplate instances
accounts: List of Account instances
contacts: List of Contact instances
opportunities: List of Opportunity instances
count: Number of invoices to create
Returns:
List of created Invoice instances
"""
from invoices.models import Invoice, InvoiceLineItem
invoices = []
# Get CLOSED_WON opportunities for linking
won_opps = [o for o in opportunities if o.stage == "CLOSED_WON"]
for i in range(count):
# Generate issue date (weighted toward recent)
issue_date = self._generate_invoice_date()
date_str = issue_date.strftime("%Y%m%d")
# Select account and contact
account = random.choice(accounts)
# Prefer contacts linked to this account
account_contacts = (
list(account.contacts.all()) if hasattr(account, "contacts") else []
)
contact = (
random.choice(account_contacts)
if account_contacts
else random.choice(contacts)
)
# Determine status
status = self._weighted_choice(self.INVOICE_STATUS_WEIGHTS)
# Generate unique invoice number
invoice_number = f"INV-{date_str}-{i + 1:04d}"
# Optionally link to opportunity (~30% of invoices)
opportunity = None
if won_opps and random.random() < 0.3:
# Try to find an opp for this account
account_opps = [o for o in won_opps if o.account_id == account.id]
opportunity = (
random.choice(account_opps)
if account_opps
else random.choice(won_opps)
)
payment_terms = self._weighted_choice(self.PAYMENT_TERMS_WEIGHTS)
tax_rate = Decimal(str(random.choice([0, 7.5, 8.25, 10, 12, 20])))
invoice = Invoice(
invoice_title=f"Invoice for {account.name}",
invoice_number=invoice_number,
status=status,
account=account,
contact=contact,
opportunity=opportunity,
# Denormalized client fields
client_name=f"{contact.first_name} {contact.last_name}",
client_email=contact.email or "",
client_phone=contact.phone or "",
client_address_line=contact.address_line or "",
client_city=contact.city or "",
client_state=contact.state or "",
client_postcode=contact.postcode or "",
client_country=contact.country or "",
# Billing from org (could be from org company profile)
billing_address_line=getattr(org, "address_line", "") or "",
billing_city=getattr(org, "city", "") or "",
billing_state=getattr(org, "state", "") or "",
billing_postcode=getattr(org, "postcode", "") or "",
billing_country=getattr(org, "billing_country", "") or "",
# Financial
currency=org.default_currency,
discount_type=random.choice(["PERCENTAGE", "FIXED", ""])
if random.random() > 0.6
else "",
discount_value=Decimal(str(random.choice([0, 5, 10, 15])))
if random.random() > 0.6
else Decimal("0"),
tax_rate=tax_rate,
# Dates
issue_date=issue_date,
payment_terms=payment_terms,
# Template
template=random.choice(templates) if templates else None,
notes=self.fake.paragraph() if random.random() > 0.5 else "",
terms="Payment is due within the specified terms.",
org=org,
)
# Handle status-specific timestamps
if status in ["Sent", "Viewed", "Paid", "Partially_Paid", "Overdue"]:
invoice.sent_at = timezone.make_aware(
datetime.datetime.combine(issue_date, datetime.time(10, 0))
)
if status in ["Viewed", "Paid", "Partially_Paid", "Overdue"]:
invoice.viewed_at = invoice.sent_at + datetime.timedelta(
days=random.randint(1, 5)
)
if status == "Paid":
invoice.paid_at = invoice.viewed_at + datetime.timedelta(
days=random.randint(1, 15)
)
# Save invoice (this auto-generates public_token and calculates due_date)
invoice.save()
# Create 2-5 line items
num_items = random.randint(2, 5)
for order in range(num_items):
product = (
random.choice(products)
if products and random.random() > 0.3
else None
)
if product:
name = product.name
unit_price = product.price
description = product.description or ""
else:
name = self.fake.bs().title()[:100]
unit_price = Decimal(str(random.randint(100, 5000)))
description = self.fake.sentence()
InvoiceLineItem.objects.create(
invoice=invoice,
product=product,
name=name,
description=description[:500],
quantity=Decimal(str(random.choice([1, 1, 1, 2, 3, 5, 10]))),
unit_price=unit_price,
discount_type=""
if random.random() > 0.2
else random.choice(["PERCENTAGE", "FIXED"]),
discount_value=Decimal("0"),
tax_rate=tax_rate if random.random() > 0.3 else Decimal("0"),
order=order,
org=org,
)
self.stats["invoice_line_items"] += 1
# Recalculate totals after line items
invoice.recalculate_totals()
invoice.save()
# Add M2M assignments
self._add_invoice_assignments(invoice, profiles, teams)
invoices.append(invoice)
self.stats["invoices"] += 1
self.stdout.write(f" Created {len(invoices)} invoices with line items")
return invoices
def create_payments(self, org, invoices):
"""
Create payments for invoices.
Args:
org: Organization instance
invoices: List of Invoice instances
Returns:
List of created Payment instances
"""
from invoices.models import Payment
payments = []
# Only create payments for certain invoice statuses
payable_invoices = [
inv
for inv in invoices
if inv.status in ["Paid", "Partially_Paid", "Sent", "Viewed", "Overdue"]
]
for invoice in payable_invoices:
if invoice.status == "Paid":
# Full payment
payment_date = (
invoice.paid_at.date()
if invoice.paid_at
else (
invoice.issue_date
+ datetime.timedelta(days=random.randint(5, 30))
)
)
payment = Payment.objects.create(
invoice=invoice,
amount=invoice.total_amount,
payment_date=payment_date,
payment_method=self._weighted_choice(self.PAYMENT_METHOD_WEIGHTS),
reference_number=f"PAY-{self.fake.bothify('??###??').upper()}",
notes="" if random.random() > 0.3 else self.fake.sentence(),
org=org,
)
payments.append(payment)
self.stats["payments"] += 1
elif invoice.status == "Partially_Paid":
# Partial payment (30-70% of total)
partial_percent = random.uniform(0.3, 0.7)
payment_amount = invoice.total_amount * Decimal(str(partial_percent))
payment_date = invoice.issue_date + datetime.timedelta(
days=random.randint(5, 20)
)
payment = Payment.objects.create(
invoice=invoice,
amount=payment_amount.quantize(Decimal("0.01")),
payment_date=payment_date,
payment_method=self._weighted_choice(self.PAYMENT_METHOD_WEIGHTS),
reference_number=f"PAY-{self.fake.bothify('??###??').upper()}",
notes="Partial payment",
org=org,
)
payments.append(payment)
self.stats["payments"] += 1
elif random.random() < 0.3: # 30% chance of payment on other statuses
# Some random payments
payment_amount = invoice.total_amount * Decimal(
str(random.uniform(0.2, 0.5))
)
payment_date = invoice.issue_date + datetime.timedelta(
days=random.randint(10, 45)
)
payment = Payment.objects.create(
invoice=invoice,
amount=payment_amount.quantize(Decimal("0.01")),
payment_date=min(payment_date, timezone.now().date()),
payment_method=self._weighted_choice(self.PAYMENT_METHOD_WEIGHTS),
reference_number=f"PAY-{self.fake.bothify('??###??').upper()}",
notes="",
org=org,
)
payments.append(payment)
self.stats["payments"] += 1
self.stdout.write(f" Created {len(payments)} payments")
return payments
def create_estimates(
self, org, profiles, teams, products, accounts, contacts, count
):
"""
Create estimates with line items.
Args:
org: Organization instance
profiles: List of Profile instances
teams: List of Teams instances
products: List of Product instances
accounts: List of Account instances
contacts: List of Contact instances
count: Number of estimates to create
Returns:
List of created Estimate instances
"""
from invoices.models import Estimate, EstimateLineItem
estimates = []
for i in range(count):
issue_date = self._generate_invoice_date()
date_str = issue_date.strftime("%Y%m%d")
account = random.choice(accounts)
account_contacts = (
list(account.contacts.all()) if hasattr(account, "contacts") else []
)
contact = (
random.choice(account_contacts)
if account_contacts
else random.choice(contacts)
)
status = self._weighted_choice(self.ESTIMATE_STATUS_WEIGHTS)
# Calculate expiry (30-90 days from issue)
expiry_date = issue_date + datetime.timedelta(days=random.randint(30, 90))
tax_rate = Decimal(str(random.choice([0, 7.5, 8.25, 10])))
estimate = Estimate(
estimate_number=f"EST-{date_str}-{i + 1:04d}",
title=f"Estimate for {account.name}",
status=status,
account=account,
contact=contact,
client_name=f"{contact.first_name} {contact.last_name}",
client_email=contact.email or "",
client_phone=contact.phone or "",
client_address_line=contact.address_line or "",
client_city=contact.city or "",
client_state=contact.state or "",
client_postcode=contact.postcode or "",
client_country=contact.country or "",
currency=org.default_currency,
discount_type=random.choice(["PERCENTAGE", "FIXED", ""])
if random.random() > 0.7
else "",
discount_value=Decimal("0"),
tax_rate=tax_rate,
issue_date=issue_date,
expiry_date=expiry_date,
notes=self.fake.paragraph() if random.random() > 0.5 else "",
terms="This estimate is valid for 30 days.",
org=org,
)
# Status timestamps
if status in ["Sent", "Viewed", "Accepted", "Declined"]:
estimate.sent_at = timezone.make_aware(
datetime.datetime.combine(issue_date, datetime.time(10, 0))
)
if status in ["Viewed", "Accepted", "Declined"]:
estimate.viewed_at = estimate.sent_at + datetime.timedelta(
days=random.randint(1, 5)
)
if status == "Accepted":
estimate.accepted_at = estimate.viewed_at + datetime.timedelta(
days=random.randint(1, 10)
)
if status == "Declined":
estimate.declined_at = estimate.viewed_at + datetime.timedelta(
days=random.randint(1, 10)
)
estimate.save()
# Create 2-4 line items
num_items = random.randint(2, 4)
for order in range(num_items):
product = (
random.choice(products)
if products and random.random() > 0.3
else None
)
if product:
name = product.name
unit_price = product.price
description = product.description or ""
else:
name = self.fake.bs().title()[:100]
unit_price = Decimal(str(random.randint(500, 10000)))
description = self.fake.sentence()
EstimateLineItem.objects.create(
estimate=estimate,
product=product,
name=name,
description=description[:500],
quantity=Decimal(str(random.choice([1, 1, 2, 5]))),
unit_price=unit_price,
tax_rate=tax_rate,
order=order,
org=org,
)
self.stats["estimate_line_items"] += 1
estimate.recalculate_totals()
estimate.save()
self._add_invoice_assignments(estimate, profiles, teams)
estimates.append(estimate)
self.stats["estimates"] += 1
self.stdout.write(f" Created {len(estimates)} estimates with line items")
return estimates
def create_recurring_invoices(
self, org, profiles, teams, products, accounts, contacts, count
):
"""
Create recurring invoice templates.
Args:
org: Organization instance
profiles: List of Profile instances
teams: List of Teams instances
products: List of Product instances
accounts: List of Account instances
contacts: List of Contact instances
count: Number of recurring invoices to create
Returns:
List of created RecurringInvoice instances
"""
from invoices.models import RecurringInvoice, RecurringInvoiceLineItem
recurring = []
for i in range(count):
account = random.choice(accounts)
account_contacts = (
list(account.contacts.all()) if hasattr(account, "contacts") else []
)
contact = (
random.choice(account_contacts)
if account_contacts
else random.choice(contacts)
)
frequency = self._weighted_choice(self.RECURRING_FREQUENCY_WEIGHTS)
start_date = self.fake.date_between(start_date="-60d", end_date="+30d")
# Some recurring invoices have end dates
end_date = None
if random.random() > 0.6:
end_date = start_date + datetime.timedelta(
days=random.randint(180, 730)
)
tax_rate = Decimal(str(random.choice([0, 8, 10, 12])))
rec = RecurringInvoice(
title=f"Recurring - {account.name} ({frequency.replace('_', ' ').title()})",
is_active=random.random() > 0.15, # 85% active
account=account,
contact=contact,
client_name=f"{contact.first_name} {contact.last_name}",
client_email=contact.email or "",
frequency=frequency,
custom_days=random.randint(7, 90) if frequency == "CUSTOM" else None,
start_date=start_date,
end_date=end_date,
next_generation_date=max(start_date, timezone.now().date()),
payment_terms=self._weighted_choice(self.PAYMENT_TERMS_WEIGHTS),
auto_send=random.random() > 0.5,
currency=org.default_currency,
discount_type=random.choice(["", "PERCENTAGE"]),
discount_value=Decimal("0"),
tax_rate=tax_rate,
notes="Auto-generated recurring invoice",
invoices_generated=random.randint(0, 12)
if start_date < timezone.now().date()
else 0,
org=org,
)
rec.save()
# Create 1-3 line items
num_items = random.randint(1, 3)
for order in range(num_items):
product = random.choice(products) if products else None
if product:
name = product.name
unit_price = product.price
description = product.description or ""
else:
name = f"Monthly {self.fake.word().title()} Service"
unit_price = Decimal(str(random.randint(99, 999)))
description = "Recurring service"
RecurringInvoiceLineItem.objects.create(
recurring_invoice=rec,
product=product,
name=name,
description=description[:500],
quantity=Decimal("1"),
unit_price=unit_price,
tax_rate=tax_rate,
order=order,
org=org,
)
self.stats["recurring_invoice_line_items"] += 1
self._add_invoice_assignments(rec, profiles, teams)
recurring.append(rec)
self.stats["recurring_invoices"] += 1
self.stdout.write(f" Created {len(recurring)} recurring invoices")
return recurring
def clear_invoice_data(self):
"""Clear all invoice-related data in reverse dependency order."""
from invoices.models import (
Payment,
InvoiceHistory,
InvoiceLineItem,
Invoice,
EstimateLineItem,
Estimate,
RecurringInvoiceLineItem,
RecurringInvoice,
Product,
InvoiceTemplate,
)
# Delete in reverse dependency order
Payment.objects.all().delete()
InvoiceHistory.objects.all().delete()
InvoiceLineItem.objects.all().delete()
Invoice.objects.all().delete()
EstimateLineItem.objects.all().delete()
Estimate.objects.all().delete()
RecurringInvoiceLineItem.objects.all().delete()
RecurringInvoice.objects.all().delete()
Product.objects.all().delete()
InvoiceTemplate.objects.all().delete()
self.stdout.write(
" Cleared: Payments, InvoiceHistory, InvoiceLineItems, Invoices, "
"EstimateLineItems, Estimates, RecurringInvoiceLineItems, RecurringInvoices, "
"Products, InvoiceTemplates"
)