""" 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 # 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'), ('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 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) 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})"