Files
igny8/backend/igny8_core/business/billing/models.py
2025-12-26 01:25:25 +00:00

930 lines
32 KiB
Python

"""
Billing Models for Credit System
"""
from decimal import Decimal
from django.db import models
from django.core.validators import MinValueValidator
from django.conf import settings
from igny8_core.auth.models import AccountBaseModel
from simple_history.models import HistoricalRecords
# Centralized payment method choices - single source of truth
PAYMENT_METHOD_CHOICES = [
('stripe', 'Stripe (Credit/Debit Card)'),
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer (Manual)'),
('local_wallet', 'Local Wallet (Manual)'),
('manual', 'Manual Payment'),
]
class CreditTransaction(AccountBaseModel):
"""Track all credit transactions (additions, deductions)"""
TRANSACTION_TYPE_CHOICES = [
('purchase', 'Purchase'),
('subscription', 'Subscription Renewal'),
('refund', 'Refund'),
('deduction', 'Usage Deduction'),
('adjustment', 'Manual Adjustment'),
]
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
description = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
# Payment FK - preferred over reference_id string
payment = models.ForeignKey(
'billing.Payment',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='credit_transactions',
help_text='Payment that triggered this credit transaction'
)
# Deprecated: Use payment FK instead
reference_id = models.CharField(
max_length=255,
blank=True,
help_text="DEPRECATED: Use payment FK. Legacy reference (e.g., payment id, invoice id)"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_transactions'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'transaction_type']),
models.Index(fields=['account', 'created_at']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
class CreditUsageLog(AccountBaseModel):
"""Detailed log of credit usage per AI operation"""
OPERATION_TYPE_CHOICES = [
('clustering', 'Keyword Clustering'),
('idea_generation', 'Content Ideas Generation'),
('content_generation', 'Content Generation'),
('image_generation', 'Image Generation'),
('image_prompt_extraction', 'Image Prompt Extraction'),
('linking', 'Internal Linking'),
('optimization', 'Content Optimization'),
('reparse', 'Content Reparse'),
('site_page_generation', 'Site Page Generation'),
('site_structure_generation', 'Site Structure Generation'),
('ideas', 'Content Ideas Generation'), # Legacy
('content', 'Content Generation'), # Legacy
('images', 'Image Generation'), # Legacy
]
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
model_used = models.CharField(max_length=100, blank=True)
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
related_object_id = models.IntegerField(null=True, blank=True)
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_usage_logs'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'operation_type']),
models.Index(fields=['account', 'created_at']),
models.Index(fields=['account', 'operation_type', 'created_at']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"
class CreditCostConfig(models.Model):
"""
Token-based credit pricing configuration.
ALL operations use token-to-credit conversion.
"""
# Operation identification
operation_type = models.CharField(
max_length=50,
unique=True,
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
help_text="AI operation type"
)
# Token-to-credit ratio (tokens per 1 credit)
tokens_per_credit = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Number of tokens that equal 1 credit (e.g., 100 tokens = 1 credit)"
)
# Minimum credits (for very small token usage)
min_credits = models.IntegerField(
default=1,
validators=[MinValueValidator(0)],
help_text="Minimum credits to charge regardless of token usage"
)
# Price per credit (for revenue reporting)
price_per_credit_usd = models.DecimalField(
max_digits=10,
decimal_places=4,
default=Decimal('0.01'),
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="USD price per credit (for revenue reporting)"
)
# Metadata
display_name = models.CharField(max_length=100, help_text="Human-readable name")
description = models.TextField(blank=True, help_text="What this operation does")
# Status
is_active = models.BooleanField(default=True, help_text="Enable/disable this operation")
# Audit fields
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='credit_cost_updates',
help_text="Admin who last updated"
)
# Change tracking
previous_tokens_per_credit = models.IntegerField(
null=True,
blank=True,
help_text="Tokens per credit before last update (for audit trail)"
)
# History tracking
history = HistoricalRecords()
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_cost_config'
verbose_name = 'Credit Cost Configuration'
verbose_name_plural = 'Credit Cost Configurations'
ordering = ['operation_type']
def __str__(self):
return f"{self.display_name} - {self.tokens_per_credit} tokens/credit"
def save(self, *args, **kwargs):
# Track token ratio changes
if self.pk:
try:
old = CreditCostConfig.objects.get(pk=self.pk)
if old.tokens_per_credit != self.tokens_per_credit:
self.previous_tokens_per_credit = old.tokens_per_credit
except CreditCostConfig.DoesNotExist:
pass
super().save(*args, **kwargs)
class BillingConfiguration(models.Model):
"""
System-wide billing configuration (Singleton).
Global settings for token-credit pricing.
"""
# Default token-to-credit ratio
default_tokens_per_credit = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Default: How many tokens equal 1 credit (e.g., 100)"
)
# Credit pricing
default_credit_price_usd = models.DecimalField(
max_digits=10,
decimal_places=4,
default=Decimal('0.01'),
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Default price per credit in USD"
)
# Reporting settings
enable_token_based_reporting = models.BooleanField(
default=True,
help_text="Show token metrics in all reports"
)
# Rounding settings
ROUNDING_CHOICES = [
('up', 'Round Up'),
('down', 'Round Down'),
('nearest', 'Round to Nearest'),
]
credit_rounding_mode = models.CharField(
max_length=10,
default='up',
choices=ROUNDING_CHOICES,
help_text="How to round fractional credits"
)
# Audit fields
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Admin who last updated"
)
class Meta:
app_label = 'billing'
db_table = 'igny8_billing_configuration'
verbose_name = 'Billing Configuration'
verbose_name_plural = 'Billing Configuration'
def save(self, *args, **kwargs):
"""Enforce singleton pattern"""
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_config(cls):
"""Get or create the singleton config"""
config, created = cls.objects.get_or_create(pk=1)
return config
def __str__(self):
return f"Billing Configuration (1 credit = {self.default_tokens_per_credit} tokens)"
class PlanLimitUsage(AccountBaseModel):
"""
Track monthly usage of plan limits (ideas, words, images, prompts)
Resets at start of each billing period
"""
LIMIT_TYPE_CHOICES = [
('content_ideas', 'Content Ideas'),
('content_words', 'Content Words'),
('images_basic', 'Basic Images'),
('images_premium', 'Premium Images'),
('image_prompts', 'Image Prompts'),
]
limit_type = models.CharField(
max_length=50,
choices=LIMIT_TYPE_CHOICES,
db_index=True,
help_text="Type of limit being tracked"
)
amount_used = models.IntegerField(
default=0,
validators=[MinValueValidator(0)],
help_text="Amount used in current period"
)
# Billing period tracking
period_start = models.DateField(
help_text="Start date of billing period"
)
period_end = models.DateField(
help_text="End date of billing period"
)
# Metadata
metadata = models.JSONField(
default=dict,
blank=True,
help_text="Additional tracking data (e.g., breakdown by site)"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_plan_limit_usage'
verbose_name = 'Plan Limit Usage'
verbose_name_plural = 'Plan Limit Usage Records'
unique_together = [['account', 'limit_type', 'period_start']]
ordering = ['-period_start', 'limit_type']
indexes = [
models.Index(fields=['account', 'limit_type']),
models.Index(fields=['account', 'period_start', 'period_end']),
models.Index(fields=['limit_type', 'period_start']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{account.name if account else 'No Account'} - {self.get_limit_type_display()} - {self.amount_used} used"
def is_current_period(self):
"""Check if this record is for the current billing period"""
from django.utils import timezone
today = timezone.now().date()
return self.period_start <= today <= self.period_end
def remaining_allowance(self, plan_limit):
"""Calculate remaining allowance"""
return max(0, plan_limit - self.amount_used)
def percentage_used(self, plan_limit):
"""Calculate percentage of limit used"""
if plan_limit == 0:
return 0
return min(100, int((self.amount_used / plan_limit) * 100))
class Invoice(AccountBaseModel):
"""
Invoice for subscription or credit purchases
Tracks billing invoices with line items and payment status
"""
STATUS_CHOICES = [
('draft', 'Draft'),
('pending', 'Pending'),
('paid', 'Paid'),
('void', 'Void'),
('uncollectible', 'Uncollectible'),
]
invoice_number = models.CharField(max_length=50, unique=True, db_index=True)
# Subscription relationship
subscription = models.ForeignKey(
'igny8_core_auth.Subscription',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='invoices',
help_text='Subscription this invoice is for (if subscription-based)'
)
# Amounts
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
tax = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
currency = models.CharField(max_length=3, default='USD')
# Status
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True)
# Dates
invoice_date = models.DateField(db_index=True)
due_date = models.DateField()
paid_at = models.DateTimeField(null=True, blank=True)
# Line items
line_items = models.JSONField(default=list, help_text="Invoice line items: [{description, amount, quantity}]")
# Payment integration
stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True)
payment_method = models.CharField(max_length=50, null=True, blank=True)
# Metadata
notes = models.TextField(blank=True)
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_invoices'
ordering = ['-invoice_date', '-created_at']
indexes = [
models.Index(fields=['account', 'status']),
models.Index(fields=['account', 'invoice_date']),
models.Index(fields=['invoice_number']),
]
def __str__(self):
return f"Invoice {self.invoice_number} - {self.account.name if self.account else 'No Account'}"
# ------------------------------------------------------------------
# Helpers to keep service code working with legacy field names
# ------------------------------------------------------------------
@property
def subtotal_amount(self):
return self.subtotal
@property
def tax_amount(self):
return self.tax
@property
def total_amount(self):
return self.total
@property
def billing_period_start(self):
"""Get from subscription - single source of truth"""
if self.account and hasattr(self.account, 'subscription'):
return self.account.subscription.current_period_start
return None
@property
def billing_period_end(self):
"""Get from subscription - single source of truth"""
if self.account and hasattr(self.account, 'subscription'):
return self.account.subscription.current_period_end
return None
@property
def billing_email(self):
"""Get from metadata snapshot or account"""
if self.metadata and 'billing_snapshot' in self.metadata:
return self.metadata['billing_snapshot'].get('email')
return self.account.billing_email if self.account else None
def add_line_item(self, description: str, quantity: int, unit_price: Decimal, amount: Decimal = None):
"""Append a line item and keep JSON shape consistent."""
items = list(self.line_items or [])
qty = quantity or 1
amt = Decimal(amount) if amount is not None else Decimal(unit_price) * qty
items.append({
'description': description,
'quantity': qty,
'unit_price': str(unit_price),
'amount': str(amt),
})
self.line_items = items
def calculate_totals(self):
"""Recompute subtotal, tax, and total from line_items."""
subtotal = Decimal('0')
for item in self.line_items or []:
try:
subtotal += Decimal(str(item.get('amount') or 0))
except Exception:
pass
self.subtotal = subtotal
self.total = subtotal + (self.tax or Decimal('0'))
class Payment(AccountBaseModel):
"""
Payment record for invoices
Supports: Stripe, PayPal, Manual (Bank Transfer, Local Wallet)
"""
STATUS_CHOICES = [
('pending_approval', 'Pending Approval'), # Manual payment submitted by user
('succeeded', 'Succeeded'), # Payment approved and processed
('failed', 'Failed'), # Payment rejected or failed
('refunded', 'Refunded'), # Payment refunded (rare)
]
# Use centralized payment method choices
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments')
# Amount
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=3, default='USD')
# Status
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_approval', db_index=True)
# Payment method
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
# Stripe integration
stripe_payment_intent_id = models.CharField(max_length=255, null=True, blank=True)
stripe_charge_id = models.CharField(max_length=255, null=True, blank=True)
# PayPal integration
paypal_order_id = models.CharField(max_length=255, null=True, blank=True)
paypal_capture_id = models.CharField(max_length=255, null=True, blank=True)
# Manual payment details
manual_reference = models.CharField(
max_length=255,
blank=True,
help_text="Bank transfer reference, wallet transaction ID, etc."
)
manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments")
admin_notes = models.TextField(blank=True, help_text="Internal notes on approval/rejection")
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='approved_payments'
)
approved_at = models.DateTimeField(null=True, blank=True)
# Timestamps
processed_at = models.DateTimeField(null=True, blank=True)
failed_at = models.DateTimeField(null=True, blank=True)
refunded_at = models.DateTimeField(null=True, blank=True)
# Error tracking
failure_reason = models.TextField(blank=True)
# Metadata
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# History tracking
history = HistoricalRecords()
class Meta:
app_label = 'billing'
db_table = 'igny8_payments'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'status']),
models.Index(fields=['account', 'payment_method']),
models.Index(fields=['invoice', 'status']),
]
def __str__(self):
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}"
class CreditPackage(models.Model):
"""
One-time credit purchase packages
Defines available credit bundles for purchase
"""
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True, db_index=True)
# Credits
credits = models.IntegerField(validators=[MinValueValidator(1)])
# Pricing
price = models.DecimalField(max_digits=10, decimal_places=2)
discount_percentage = models.IntegerField(default=0, help_text="Discount percentage (0-100)")
# Stripe
stripe_product_id = models.CharField(max_length=255, null=True, blank=True)
stripe_price_id = models.CharField(max_length=255, null=True, blank=True)
# PayPal
paypal_plan_id = models.CharField(max_length=255, null=True, blank=True)
# Status
is_active = models.BooleanField(default=True, db_index=True)
is_featured = models.BooleanField(default=False, help_text="Show as featured package")
# Display
description = models.TextField(blank=True)
features = models.JSONField(default=list, help_text="Bonus features or highlights")
# Sort order
sort_order = models.IntegerField(default=0, help_text="Display order (lower = first)")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_packages'
ordering = ['sort_order', 'price']
def __str__(self):
return f"{self.name} - {self.credits} credits - ${self.price}"
class PaymentMethodConfig(models.Model):
"""
Configure payment methods availability per country
Allows enabling/disabling manual payments by region
"""
# Use centralized choices
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
country_code = models.CharField(
max_length=2,
db_index=True,
help_text="ISO 2-letter country code (e.g., US, GB, IN)"
)
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES)
is_enabled = models.BooleanField(default=True)
# Display info
display_name = models.CharField(max_length=100, blank=True)
instructions = models.TextField(blank=True, help_text="Payment instructions for users")
# Manual payment details (for bank_transfer/local_wallet)
bank_name = models.CharField(max_length=255, blank=True)
account_number = models.CharField(max_length=255, blank=True)
routing_number = models.CharField(max_length=255, blank=True)
swift_code = models.CharField(max_length=255, blank=True)
# Additional fields for local wallets
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
wallet_id = models.CharField(max_length=255, blank=True)
# Webhook configuration (Stripe/PayPal)
webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks")
webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification")
api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration")
api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration")
# Order/priority
sort_order = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_payment_method_config'
unique_together = [['country_code', 'payment_method']]
ordering = ['country_code', 'sort_order']
verbose_name = 'Payment Method Configuration'
verbose_name_plural = 'Payment Method Configurations'
def __str__(self):
return f"{self.country_code} - {self.get_payment_method_display()}"
class AccountPaymentMethod(AccountBaseModel):
"""
Account-scoped payment methods (Stripe/PayPal/manual bank/wallet).
Only metadata/refs are stored here; no secrets.
"""
# Use centralized choices
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
display_name = models.CharField(max_length=100, help_text="User-visible label", default='')
is_default = models.BooleanField(default=False, db_index=True)
is_enabled = models.BooleanField(default=True, db_index=True)
is_verified = models.BooleanField(default=False, db_index=True)
country_code = models.CharField(max_length=2, blank=True, default='', help_text="ISO-2 country code (optional)")
# Manual/bank/local wallet details (non-sensitive metadata)
instructions = models.TextField(blank=True, default='')
metadata = models.JSONField(default=dict, blank=True, help_text="Provider references or display metadata")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_account_payment_methods'
ordering = ['-is_default', 'display_name', 'id']
indexes = [
models.Index(fields=['account', 'is_default']),
models.Index(fields=['account', 'type']),
]
unique_together = [['account', 'display_name']]
def __str__(self):
return f"{self.account_id} - {self.display_name} ({self.type})"
class AIModelConfig(models.Model):
"""
AI Model Configuration - Database-driven model pricing and capabilities.
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
Two pricing models:
- Text models: Cost per 1M tokens (input/output), credits calculated AFTER AI call
- Image models: Cost per image, credits calculated BEFORE AI call
"""
MODEL_TYPE_CHOICES = [
('text', 'Text Generation'),
('image', 'Image Generation'),
('embedding', 'Embedding'),
]
PROVIDER_CHOICES = [
('openai', 'OpenAI'),
('anthropic', 'Anthropic'),
('runware', 'Runware'),
('google', 'Google'),
]
# Basic Information
model_name = models.CharField(
max_length=100,
unique=True,
db_index=True,
help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')"
)
display_name = models.CharField(
max_length=200,
help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')"
)
model_type = models.CharField(
max_length=20,
choices=MODEL_TYPE_CHOICES,
db_index=True,
help_text="Type of model - determines which pricing fields are used"
)
provider = models.CharField(
max_length=50,
choices=PROVIDER_CHOICES,
db_index=True,
help_text="AI provider (OpenAI, Anthropic, etc.)"
)
# Text Model Pricing (Only for model_type='text')
input_cost_per_1m = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Cost per 1 million input tokens (USD). For text models only."
)
output_cost_per_1m = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Cost per 1 million output tokens (USD). For text models only."
)
context_window = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1)],
help_text="Maximum input tokens (context length). For text models only."
)
max_output_tokens = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1)],
help_text="Maximum output tokens per request. For text models only."
)
# Image Model Pricing (Only for model_type='image')
cost_per_image = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Fixed cost per image generation (USD). For image models only."
)
valid_sizes = models.JSONField(
null=True,
blank=True,
help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.'
)
# Capabilities
supports_json_mode = models.BooleanField(
default=False,
help_text="True for models with JSON response format support"
)
supports_vision = models.BooleanField(
default=False,
help_text="True for models that can analyze images"
)
supports_function_calling = models.BooleanField(
default=False,
help_text="True for models with function calling capability"
)
# Status & Configuration
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Enable/disable model without deleting"
)
is_default = models.BooleanField(
default=False,
db_index=True,
help_text="Mark as default model for its type (only one per type)"
)
sort_order = models.IntegerField(
default=0,
help_text="Control order in dropdown lists (lower numbers first)"
)
# Metadata
description = models.TextField(
blank=True,
help_text="Admin notes about model usage, strengths, limitations"
)
release_date = models.DateField(
null=True,
blank=True,
help_text="When model was released/added"
)
deprecation_date = models.DateField(
null=True,
blank=True,
help_text="When model will be removed"
)
# Audit Fields
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='ai_model_updates',
help_text="Admin who last updated"
)
# History tracking
history = HistoricalRecords()
class Meta:
app_label = 'billing'
db_table = 'igny8_ai_model_config'
verbose_name = 'AI Model Configuration'
verbose_name_plural = 'AI Model Configurations'
ordering = ['model_type', 'sort_order', 'model_name']
indexes = [
models.Index(fields=['model_type', 'is_active']),
models.Index(fields=['provider', 'is_active']),
models.Index(fields=['is_default', 'model_type']),
]
def __str__(self):
return self.display_name
def save(self, *args, **kwargs):
"""Ensure only one is_default per model_type"""
if self.is_default:
# Unset other defaults for same model_type
AIModelConfig.objects.filter(
model_type=self.model_type,
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
def get_cost_for_tokens(self, input_tokens, output_tokens):
"""Calculate cost for text models based on token usage"""
if self.model_type != 'text':
raise ValueError("get_cost_for_tokens only applies to text models")
if not self.input_cost_per_1m or not self.output_cost_per_1m:
raise ValueError(f"Model {self.model_name} missing cost_per_1m values")
cost = (
(Decimal(input_tokens) * self.input_cost_per_1m) +
(Decimal(output_tokens) * self.output_cost_per_1m)
) / Decimal('1000000')
return cost
def get_cost_for_images(self, num_images):
"""Calculate cost for image models"""
if self.model_type != 'image':
raise ValueError("get_cost_for_images only applies to image models")
if not self.cost_per_image:
raise ValueError(f"Model {self.model_name} missing cost_per_image")
return self.cost_per_image * Decimal(num_images)
def validate_size(self, size):
"""Check if size is valid for this image model"""
if self.model_type != 'image':
raise ValueError("validate_size only applies to image models")
if not self.valid_sizes:
return True # No size restrictions
return size in self.valid_sizes
def get_display_with_pricing(self):
"""For dropdowns: show model with pricing"""
if self.model_type == 'text':
return f"{self.display_name} - ${self.input_cost_per_1m}/${self.output_cost_per_1m} per 1M"
elif self.model_type == 'image':
return f"{self.display_name} - ${self.cost_per_image} per image"
return self.display_name