585 lines
22 KiB
Python
585 lines
22 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
|
|
|
|
|
|
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.)")
|
|
reference_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
help_text="Optional 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'),
|
|
('reparse', 'Content Reparse'),
|
|
('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):
|
|
"""
|
|
Configurable credit costs per AI function
|
|
Admin-editable alternative to hardcoded constants
|
|
"""
|
|
# Operation identification
|
|
operation_type = models.CharField(
|
|
max_length=50,
|
|
unique=True,
|
|
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
|
|
help_text="AI operation type"
|
|
)
|
|
|
|
# Cost configuration
|
|
credits_cost = models.IntegerField(
|
|
validators=[MinValueValidator(0)],
|
|
help_text="Credits required for this operation"
|
|
)
|
|
|
|
# Unit of measurement
|
|
UNIT_CHOICES = [
|
|
('per_request', 'Per Request'),
|
|
('per_100_words', 'Per 100 Words'),
|
|
('per_200_words', 'Per 200 Words'),
|
|
('per_item', 'Per Item'),
|
|
('per_image', 'Per Image'),
|
|
]
|
|
|
|
unit = models.CharField(
|
|
max_length=50,
|
|
default='per_request',
|
|
choices=UNIT_CHOICES,
|
|
help_text="What the cost applies to"
|
|
)
|
|
|
|
# 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_cost = models.IntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Cost before last update (for audit trail)"
|
|
)
|
|
|
|
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.credits_cost} credits {self.unit}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Track cost changes
|
|
if self.pk:
|
|
try:
|
|
old = CreditCostConfig.objects.get(pk=self.pk)
|
|
if old.credits_cost != self.credits_cost:
|
|
self.previous_cost = old.credits_cost
|
|
except CreditCostConfig.DoesNotExist:
|
|
pass
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
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)
|
|
|
|
# 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)
|
|
]
|
|
|
|
PAYMENT_METHOD_CHOICES = [
|
|
('stripe', 'Stripe (Credit/Debit Card)'),
|
|
('paypal', 'PayPal'),
|
|
('bank_transfer', 'Bank Transfer (Manual)'),
|
|
('local_wallet', 'Local Wallet (Manual)'),
|
|
('manual', 'Manual Payment'),
|
|
]
|
|
|
|
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', 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)
|
|
|
|
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}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Override save to automatically update related objects when payment is approved.
|
|
When status changes to 'succeeded', automatically:
|
|
1. Mark invoice as paid
|
|
2. Activate subscription
|
|
3. Activate account
|
|
4. Add credits
|
|
"""
|
|
# Check if status is changing to succeeded
|
|
is_new = self.pk is None
|
|
old_status = None
|
|
|
|
if not is_new:
|
|
try:
|
|
old_payment = Payment.objects.get(pk=self.pk)
|
|
old_status = old_payment.status
|
|
except Payment.DoesNotExist:
|
|
pass
|
|
|
|
# If status is changing to succeeded, trigger approval workflow
|
|
if self.status == 'succeeded' and old_status != 'succeeded':
|
|
from django.utils import timezone
|
|
from django.db import transaction
|
|
from igny8_core.business.billing.services.credit_service import CreditService
|
|
|
|
# Set approval timestamp if not set
|
|
if not self.processed_at:
|
|
self.processed_at = timezone.now()
|
|
if not self.approved_at:
|
|
self.approved_at = timezone.now()
|
|
|
|
# Save payment first
|
|
super().save(*args, **kwargs)
|
|
|
|
# Then update related objects in transaction
|
|
with transaction.atomic():
|
|
# 1. Update Invoice
|
|
if self.invoice:
|
|
self.invoice.status = 'paid'
|
|
self.invoice.paid_at = timezone.now()
|
|
self.invoice.save(update_fields=['status', 'paid_at'])
|
|
|
|
# 2. Update Account (MUST be before subscription check)
|
|
if self.account:
|
|
self.account.status = 'active'
|
|
self.account.save(update_fields=['status'])
|
|
|
|
# 3. Update Subscription via account.subscription (one-to-one relationship)
|
|
try:
|
|
if hasattr(self.account, 'subscription'):
|
|
subscription = self.account.subscription
|
|
subscription.status = 'active'
|
|
subscription.external_payment_id = self.manual_reference or f'payment-{self.id}'
|
|
subscription.save(update_fields=['status', 'external_payment_id'])
|
|
|
|
# 4. Add Credits from subscription plan
|
|
if subscription.plan and subscription.plan.included_credits > 0:
|
|
CreditService.add_credits(
|
|
account=self.account,
|
|
amount=subscription.plan.included_credits,
|
|
transaction_type='subscription',
|
|
description=f'{subscription.plan.name} - Invoice {self.invoice.invoice_number}',
|
|
metadata={
|
|
'subscription_id': subscription.id,
|
|
'invoice_id': self.invoice.id,
|
|
'payment_id': self.id,
|
|
'auto_approved': True
|
|
}
|
|
)
|
|
except Exception as e:
|
|
# Log error but don't fail payment save
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f'Error updating subscription/credits for payment {self.id}: {e}', exc_info=True)
|
|
else:
|
|
# Normal save
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
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
|
|
"""
|
|
PAYMENT_METHOD_CHOICES = [
|
|
('stripe', 'Stripe'),
|
|
('paypal', 'PayPal'),
|
|
('bank_transfer', 'Bank Transfer'),
|
|
('local_wallet', 'Local Wallet'),
|
|
]
|
|
|
|
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)
|
|
|
|
# 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.
|
|
"""
|
|
PAYMENT_METHOD_CHOICES = [
|
|
('stripe', 'Stripe'),
|
|
('paypal', 'PayPal'),
|
|
('bank_transfer', 'Bank Transfer'),
|
|
('local_wallet', 'Local Wallet'),
|
|
]
|
|
|
|
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})"
|