feat: add Usage Limits Panel component with usage tracking and visual indicators for limits

style: implement custom color schemes and gradients for account section, enhancing visual hierarchy
This commit is contained in:
IGNY8 VPS (Salman)
2025-12-12 13:15:15 +00:00
parent 12956ec64a
commit 6e2101d019
29 changed files with 3622 additions and 85 deletions

View File

@@ -111,17 +111,26 @@ class AccountAdminForm(forms.ModelForm):
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
"""Plan admin - Global, no account filtering needed"""
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'included_credits', 'is_active']
list_filter = ['is_active', 'billing_cycle']
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active']
list_filter = ['is_active', 'billing_cycle', 'is_internal']
search_fields = ['name', 'slug']
readonly_fields = ['created_at']
fieldsets = (
('Plan Info', {
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', 'is_internal')
}),
('Account Management Limits', {
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'),
'description': 'Persistent limits for account-level resources'
}),
('Hard Limits (Persistent)', {
'fields': ('max_keywords', 'max_clusters'),
'description': 'Total allowed - never reset'
}),
('Monthly Limits (Reset on Billing Cycle)', {
'fields': ('max_content_ideas', 'max_content_words', 'max_images_basic', 'max_images_premium', 'max_image_prompts'),
'description': 'Monthly allowances - reset at billing cycle'
}),
('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.8 on 2025-12-12 11:26
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0012_fix_subscription_constraints'),
]
operations = [
migrations.AddField(
model_name='plan',
name='max_clusters',
field=models.IntegerField(default=100, help_text='Maximum AI keyword clusters allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_content_ideas',
field=models.IntegerField(default=300, help_text='Maximum AI content ideas per month', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_content_words',
field=models.IntegerField(default=100000, help_text='Maximum content words per month (e.g., 100000 = 100K words)', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_image_prompts',
field=models.IntegerField(default=300, help_text='Maximum image prompts per month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_images_basic',
field=models.IntegerField(default=300, help_text='Maximum basic AI images per month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_images_premium',
field=models.IntegerField(default=60, help_text='Maximum premium AI images per month (DALL-E)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_keywords',
field=models.IntegerField(default=1000, help_text='Maximum total keywords allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.8 on 2025-12-12 12:24
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
]
operations = [
migrations.AddField(
model_name='account',
name='usage_content_ideas',
field=models.IntegerField(default=0, help_text='Content ideas generated this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_content_words',
field=models.IntegerField(default=0, help_text='Content words generated this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_image_prompts',
field=models.IntegerField(default=0, help_text='Image prompts this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_images_basic',
field=models.IntegerField(default=0, help_text='Basic AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_images_premium',
field=models.IntegerField(default=0, help_text='Premium AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_period_end',
field=models.DateTimeField(blank=True, help_text='Current billing period end', null=True),
),
migrations.AddField(
model_name='account',
name='usage_period_start',
field=models.DateTimeField(blank=True, help_text='Current billing period start', null=True),
),
]

View File

@@ -106,6 +106,15 @@ class Account(SoftDeletableModel):
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
# Monthly usage tracking (reset on billing cycle)
usage_content_ideas = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content ideas generated this month")
usage_content_words = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content words generated this month")
usage_images_basic = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Basic AI images this month")
usage_images_premium = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Premium AI images this month")
usage_image_prompts = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Image prompts this month")
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -192,6 +201,45 @@ class Plan(models.Model):
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
# Hard Limits (Persistent - user manages within limit)
max_keywords = models.IntegerField(
default=1000,
validators=[MinValueValidator(1)],
help_text="Maximum total keywords allowed (hard limit)"
)
max_clusters = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Maximum AI keyword clusters allowed (hard limit)"
)
# Monthly Limits (Reset on billing cycle)
max_content_ideas = models.IntegerField(
default=300,
validators=[MinValueValidator(1)],
help_text="Maximum AI content ideas per month"
)
max_content_words = models.IntegerField(
default=100000,
validators=[MinValueValidator(1)],
help_text="Maximum content words per month (e.g., 100000 = 100K words)"
)
max_images_basic = models.IntegerField(
default=300,
validators=[MinValueValidator(0)],
help_text="Maximum basic AI images per month"
)
max_images_premium = models.IntegerField(
default=60,
validators=[MinValueValidator(0)],
help_text="Maximum premium AI images per month (DALL-E)"
)
max_image_prompts = models.IntegerField(
default=300,
validators=[MinValueValidator(0)],
help_text="Maximum image prompts per month"
)
# Billing & Credits (Phase 0: Credit-only system)
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")

View File

@@ -13,6 +13,9 @@ class PlanSerializer(serializers.ModelSerializer):
'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent',
'is_featured', 'features', 'is_active',
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
'max_keywords', 'max_clusters',
'max_content_ideas', 'max_content_words',
'max_images_basic', 'max_images_premium', 'max_image_prompts',
'included_credits', 'extra_credit_price', 'allow_credit_topup',
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
'stripe_product_id', 'stripe_price_id', 'credits_per_month'

View File

@@ -529,6 +529,14 @@ class SiteViewSet(AccountModelViewSet):
if user and user.is_authenticated:
account = getattr(user, 'account', None)
# Check hard limit for sites
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
try:
LimitService.check_hard_limit(account, 'sites', additional_count=1)
except HardLimitExceededError as e:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(str(e))
# Multiple sites can be active simultaneously - no constraint
site = serializer.save(account=account)

View File

@@ -189,6 +189,83 @@ class CreditCostConfig(models.Model):
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

View File

@@ -0,0 +1,357 @@
"""
Limit Service for Plan Limit Enforcement
Manages hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images, prompts)
"""
from django.db import transaction
from django.utils import timezone
from datetime import timedelta
import logging
from igny8_core.auth.models import Account
logger = logging.getLogger(__name__)
class LimitExceededError(Exception):
"""Base exception for limit exceeded errors"""
pass
class HardLimitExceededError(LimitExceededError):
"""Raised when a hard limit (sites, users, keywords, clusters) is exceeded"""
pass
class MonthlyLimitExceededError(LimitExceededError):
"""Raised when a monthly limit (ideas, words, images, prompts) is exceeded"""
pass
class LimitService:
"""Service for managing and enforcing plan limits"""
# Map limit types to model/field names
HARD_LIMIT_MAPPINGS = {
'sites': {
'model': 'igny8_core_auth.Site',
'plan_field': 'max_sites',
'display_name': 'Sites',
'filter_field': 'account',
},
'users': {
'model': 'igny8_core_auth.SiteUserAccess',
'plan_field': 'max_users',
'display_name': 'Team Users',
'filter_field': 'site__account',
},
'keywords': {
'model': 'planner.Keywords',
'plan_field': 'max_keywords',
'display_name': 'Keywords',
'filter_field': 'account',
},
'clusters': {
'model': 'planner.Clusters',
'plan_field': 'max_clusters',
'display_name': 'Clusters',
'filter_field': 'account',
},
}
MONTHLY_LIMIT_MAPPINGS = {
'content_ideas': {
'plan_field': 'max_content_ideas',
'usage_field': 'usage_content_ideas',
'display_name': 'Content Ideas',
},
'content_words': {
'plan_field': 'max_content_words',
'usage_field': 'usage_content_words',
'display_name': 'Content Words',
},
'images_basic': {
'plan_field': 'max_images_basic',
'usage_field': 'usage_images_basic',
'display_name': 'Basic Images',
},
'images_premium': {
'plan_field': 'max_images_premium',
'usage_field': 'usage_images_premium',
'display_name': 'Premium Images',
},
'image_prompts': {
'plan_field': 'max_image_prompts',
'usage_field': 'usage_image_prompts',
'display_name': 'Image Prompts',
},
}
@staticmethod
def check_hard_limit(account: Account, limit_type: str, additional_count: int = 1) -> bool:
"""
Check if adding items would exceed hard limit.
Args:
account: Account instance
limit_type: Type of limit
additional_count: Number of items to add
Returns:
bool: True if within limit
Raises:
HardLimitExceededError: If limit would be exceeded
"""
from django.apps import apps
if limit_type not in LimitService.HARD_LIMIT_MAPPINGS:
raise ValueError(f"Invalid hard limit type: {limit_type}")
config = LimitService.HARD_LIMIT_MAPPINGS[limit_type]
plan = account.plan
if not plan:
raise ValueError("Account has no plan")
plan_limit = getattr(plan, config['plan_field'])
model_path = config['model']
app_label, model_name = model_path.split('.')
Model = apps.get_model(app_label, model_name)
filter_field = config.get('filter_field', 'account')
filter_kwargs = {filter_field: account}
current_count = Model.objects.filter(**filter_kwargs).count()
new_count = current_count + additional_count
logger.info(f"Hard limit check: {limit_type} - Current: {current_count}, Requested: {additional_count}, Limit: {plan_limit}")
if new_count > plan_limit:
raise HardLimitExceededError(
f"{config['display_name']} limit exceeded. "
f"Current: {current_count}, Limit: {plan_limit}. "
f"Upgrade your plan to increase this limit."
)
return True
@staticmethod
def check_monthly_limit(account: Account, limit_type: str, amount: int = 1) -> bool:
"""
Check if operation would exceed monthly limit.
Args:
account: Account instance
limit_type: Type of limit
amount: Amount to use
Returns:
bool: True if within limit
Raises:
MonthlyLimitExceededError: If limit would be exceeded
"""
if limit_type not in LimitService.MONTHLY_LIMIT_MAPPINGS:
raise ValueError(f"Invalid monthly limit type: {limit_type}")
config = LimitService.MONTHLY_LIMIT_MAPPINGS[limit_type]
plan = account.plan
if not plan:
raise ValueError("Account has no plan")
plan_limit = getattr(plan, config['plan_field'])
current_usage = getattr(account, config['usage_field'], 0)
new_usage = current_usage + amount
logger.info(f"Monthly limit check: {limit_type} - Current: {current_usage}, Requested: {amount}, Limit: {plan_limit}")
if new_usage > plan_limit:
period_end = account.usage_period_end or timezone.now().date()
raise MonthlyLimitExceededError(
f"{config['display_name']} limit exceeded. "
f"Used: {current_usage}, Requested: {amount}, Limit: {plan_limit}. "
f"Resets on {period_end.strftime('%B %d, %Y')}. "
f"Upgrade your plan or wait for reset."
)
return True
@staticmethod
@transaction.atomic
def increment_usage(account: Account, limit_type: str, amount: int = 1, metadata: dict = None) -> int:
"""
Increment monthly usage after successful operation.
Args:
account: Account instance
limit_type: Type of limit
amount: Amount to increment
metadata: Optional metadata
Returns:
int: New usage amount
"""
if limit_type not in LimitService.MONTHLY_LIMIT_MAPPINGS:
raise ValueError(f"Invalid monthly limit type: {limit_type}")
config = LimitService.MONTHLY_LIMIT_MAPPINGS[limit_type]
usage_field = config['usage_field']
current_usage = getattr(account, usage_field, 0)
new_usage = current_usage + amount
setattr(account, usage_field, new_usage)
account.save(update_fields=[usage_field, 'updated_at'])
logger.info(f"Incremented {limit_type} usage by {amount}. New total: {new_usage}")
return new_usage
@staticmethod
def get_current_period(account: Account) -> tuple:
"""
Get current billing period start and end dates from account.
Args:
account: Account instance
Returns:
tuple: (period_start, period_end) as datetime objects
"""
if account.usage_period_start and account.usage_period_end:
return account.usage_period_start, account.usage_period_end
subscription = getattr(account, 'subscription', None)
if subscription and hasattr(subscription, 'current_period_start'):
period_start = subscription.current_period_start
period_end = subscription.current_period_end
else:
now = timezone.now()
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
next_month = now.replace(year=now.year + 1, month=1, day=1)
else:
next_month = now.replace(month=now.month + 1, day=1)
period_end = next_month - timedelta(days=1)
period_end = period_end.replace(hour=23, minute=59, second=59)
account.usage_period_start = period_start
account.usage_period_end = period_end
account.save(update_fields=['usage_period_start', 'usage_period_end', 'updated_at'])
return period_start, period_end
@staticmethod
def get_usage_summary(account: Account) -> dict:
"""
Get comprehensive usage summary for all limits.
Args:
account: Account instance
Returns:
dict: Usage summary with hard and monthly limits
"""
from django.apps import apps
plan = account.plan
if not plan:
return {'error': 'No plan assigned to account'}
period_start, period_end = LimitService.get_current_period(account)
days_until_reset = (period_end.date() - timezone.now().date()).days if period_end else 0
summary = {
'account_id': account.id,
'account_name': account.name,
'plan_name': plan.name,
'period_start': period_start.isoformat() if period_start else None,
'period_end': period_end.isoformat() if period_end else None,
'days_until_reset': days_until_reset,
'hard_limits': {},
'monthly_limits': {},
}
for limit_type, config in LimitService.HARD_LIMIT_MAPPINGS.items():
model_path = config['model']
app_label, model_name = model_path.split('.')
Model = apps.get_model(app_label, model_name)
filter_field = config.get('filter_field', 'account')
filter_kwargs = {filter_field: account}
current_count = Model.objects.filter(**filter_kwargs).count()
plan_limit = getattr(plan, config['plan_field'])
summary['hard_limits'][limit_type] = {
'display_name': config['display_name'],
'current': current_count,
'limit': plan_limit,
'remaining': max(0, plan_limit - current_count),
'percentage_used': int((current_count / plan_limit) * 100) if plan_limit > 0 else 0,
}
for limit_type, config in LimitService.MONTHLY_LIMIT_MAPPINGS.items():
plan_limit = getattr(plan, config['plan_field'])
current_usage = getattr(account, config['usage_field'], 0)
summary['monthly_limits'][limit_type] = {
'display_name': config['display_name'],
'current': current_usage,
'limit': plan_limit,
'remaining': max(0, plan_limit - current_usage),
'percentage_used': int((current_usage / plan_limit) * 100) if plan_limit > 0 else 0,
}
return summary
@staticmethod
@transaction.atomic
def reset_monthly_limits(account: Account) -> dict:
"""
Reset all monthly limits for an account.
Args:
account: Account instance
Returns:
dict: Summary of reset operation
"""
account.usage_content_ideas = 0
account.usage_content_words = 0
account.usage_images_basic = 0
account.usage_images_premium = 0
account.usage_image_prompts = 0
old_period_end = account.usage_period_end
now = timezone.now()
new_period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
next_month = now.replace(year=now.year + 1, month=1, day=1)
else:
next_month = now.replace(month=now.month + 1, day=1)
new_period_end = next_month - timedelta(days=1)
new_period_end = new_period_end.replace(hour=23, minute=59, second=59)
account.usage_period_start = new_period_start
account.usage_period_end = new_period_end
account.save(update_fields=[
'usage_content_ideas', 'usage_content_words',
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
'usage_period_start', 'usage_period_end', 'updated_at'
])
logger.info(f"Reset monthly limits for account {account.id}")
return {
'account_id': account.id,
'old_period_end': old_period_end.isoformat() if old_period_end else None,
'new_period_start': new_period_start.isoformat(),
'new_period_end': new_period_end.isoformat(),
'limits_reset': 5,
}

View File

@@ -7,6 +7,7 @@ from .views import (
PaymentViewSet,
CreditPackageViewSet,
AccountPaymentMethodViewSet,
get_usage_summary,
)
from igny8_core.modules.billing.views import (
CreditBalanceViewSet,
@@ -29,4 +30,6 @@ router.register(r'payment-configs', BillingViewSet, basename='payment-configs')
urlpatterns = [
path('', include(router.urls)),
# User-facing usage summary endpoint for plan limits
path('usage-summary/', get_usage_summary, name='usage-summary'),
]

View File

@@ -866,3 +866,44 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
request=request
)
# ============================================================================
# USAGE SUMMARY (Plan Limits) - User-facing endpoint
# ============================================================================
from rest_framework.decorators import api_view, permission_classes
@api_view(['GET'])
@permission_classes([IsAuthenticatedAndActive, HasTenantAccess])
def get_usage_summary(request):
"""
Get comprehensive usage summary for current account.
Includes hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images).
GET /api/v1/billing/usage-summary/
"""
try:
account = getattr(request, 'account', None)
if not account:
return error_response(
error='Account not found.',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
from igny8_core.business.billing.services.limit_service import LimitService
summary = LimitService.get_usage_summary(account)
return success_response(
data=summary,
message='Usage summary retrieved successfully.',
request=request
)
except Exception as e:
logger.error(f'Error getting usage summary: {str(e)}', exc_info=True)
return error_response(
error=f'Failed to retrieve usage summary: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)

View File

@@ -276,6 +276,55 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
def __str__(self):
return self.title or f"Content {self.id}"
def save(self, *args, **kwargs):
"""Override save to auto-calculate word_count from content_html"""
is_new = self.pk is None
old_word_count = 0
# Get old word count if updating
if not is_new and self.content_html:
try:
old_instance = Content.objects.get(pk=self.pk)
old_word_count = old_instance.word_count or 0
except Content.DoesNotExist:
pass
# Auto-calculate word count if content_html has changed
if self.content_html:
from igny8_core.utils.word_counter import calculate_word_count
calculated_count = calculate_word_count(self.content_html)
# Only update if different to avoid unnecessary saves
if self.word_count != calculated_count:
self.word_count = calculated_count
super().save(*args, **kwargs)
# Increment usage for new content or if word count increased
if self.content_html and self.word_count:
# Only count newly generated words
new_words = self.word_count - old_word_count if not is_new else self.word_count
if new_words > 0:
from igny8_core.business.billing.services.limit_service import LimitService
try:
# Get account from site
account = self.site.account if self.site else None
if account:
LimitService.increment_usage(
account=account,
limit_type='content_words',
amount=new_words,
metadata={
'content_id': self.id,
'content_title': self.title,
'site_id': self.site.id if self.site else None,
}
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error incrementing word usage for content {self.id}: {str(e)}")
class ContentTaxonomy(SiteSectorBaseModel):

View File

@@ -30,12 +30,20 @@ class ContentGenerationService:
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
from igny8_core.business.billing.services.limit_service import LimitService, MonthlyLimitExceededError
# Get tasks
tasks = Tasks.objects.filter(id__in=task_ids, account=account)
# Calculate estimated credits needed based on word count
total_word_count = sum(task.word_count or 1000 for task in tasks)
# Check monthly word count limit
try:
LimitService.check_monthly_limit(account, 'content_words', amount=total_word_count)
except MonthlyLimitExceededError as e:
raise InsufficientCreditsError(str(e))
# Check credits
try:
self.credit_service.check_credits(account, 'content_generation', total_word_count)

View File

@@ -25,6 +25,15 @@ app.conf.beat_schedule = {
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
},
# Plan Limits Tasks
'reset-monthly-plan-limits': {
'task': 'reset_monthly_plan_limits',
'schedule': crontab(hour=0, minute=30), # Daily at 00:30 to check for period end
},
'check-approaching-limits': {
'task': 'check_approaching_limits',
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
},
# Automation Tasks
'check-scheduled-automations': {
'task': 'automation.check_scheduled_automations',

View File

@@ -1,53 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-09 13:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0013_add_webhook_config'),
]
operations = [
migrations.RemoveIndex(
model_name='payment',
name='payment_account_status_created_idx',
),
migrations.RemoveField(
model_name='invoice',
name='billing_email',
),
migrations.RemoveField(
model_name='invoice',
name='billing_period_end',
),
migrations.RemoveField(
model_name='invoice',
name='billing_period_start',
),
migrations.RemoveField(
model_name='payment',
name='transaction_reference',
),
migrations.AlterField(
model_name='accountpaymentmethod',
name='type',
field=models.CharField(choices=[('stripe', 'Stripe (Credit/Debit Card)'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer (Manual)'), ('local_wallet', 'Local Wallet (Manual)'), ('manual', 'Manual Payment')], db_index=True, max_length=50),
),
migrations.AlterField(
model_name='credittransaction',
name='reference_id',
field=models.CharField(blank=True, help_text='DEPRECATED: Use payment FK. Legacy reference (e.g., payment id, invoice id)', max_length=255),
),
migrations.AlterField(
model_name='paymentmethodconfig',
name='payment_method',
field=models.CharField(choices=[('stripe', 'Stripe (Credit/Debit Card)'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer (Manual)'), ('local_wallet', 'Local Wallet (Manual)'), ('manual', 'Manual Payment')], max_length=50),
),
migrations.AlterField(
model_name='paymentmethodconfig',
name='webhook_url',
field=models.URLField(blank=True, help_text='Webhook URL for payment gateway callbacks'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.8 on 2025-12-12 11:26
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0013_add_webhook_config'),
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
]
operations = [
migrations.CreateModel(
name='PlanLimitUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('limit_type', models.CharField(choices=[('content_ideas', 'Content Ideas'), ('content_words', 'Content Words'), ('images_basic', 'Basic Images'), ('images_premium', 'Premium Images'), ('image_prompts', 'Image Prompts')], db_index=True, help_text='Type of limit being tracked', max_length=50)),
('amount_used', models.IntegerField(default=0, help_text='Amount used in current period', validators=[django.core.validators.MinValueValidator(0)])),
('period_start', models.DateField(help_text='Start date of billing period')),
('period_end', models.DateField(help_text='End date of billing period')),
('metadata', models.JSONField(blank=True, default=dict, 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)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
],
options={
'verbose_name': 'Plan Limit Usage',
'verbose_name_plural': 'Plan Limit Usage Records',
'db_table': 'igny8_plan_limit_usage',
'ordering': ['-period_start', 'limit_type'],
'indexes': [models.Index(fields=['account', 'limit_type'], name='igny8_plan__tenant__993f7b_idx'), models.Index(fields=['account', 'period_start', 'period_end'], name='igny8_plan__tenant__aba01f_idx'), models.Index(fields=['limit_type', 'period_start'], name='igny8_plan__limit_t_d0f5ef_idx')],
'unique_together': {('account', 'limit_type', 'period_start')},
},
),
]

View File

@@ -0,0 +1,168 @@
"""
Plan Limits Reset Task
Scheduled task to reset monthly plan limits at billing period end
"""
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
import logging
from igny8_core.auth.models import Account
from igny8_core.business.billing.services.limit_service import LimitService
logger = logging.getLogger(__name__)
@shared_task(name='reset_monthly_plan_limits')
def reset_monthly_plan_limits():
"""
Reset monthly plan limits for accounts whose billing period has ended.
This task should run daily (recommended: midnight UTC).
It finds all accounts where the billing period has ended and resets their monthly usage.
Monthly limits that get reset:
- content_ideas
- content_words
- images_basic
- images_premium
- image_prompts
Hard limits (sites, users, keywords, clusters) are NOT reset.
"""
logger.info("Starting monthly plan limits reset task")
today = timezone.now().date()
reset_count = 0
error_count = 0
# Find all active accounts with subscriptions
accounts = Account.objects.filter(
status='active',
subscription__isnull=False
).select_related('subscription', 'plan')
logger.info(f"Found {accounts.count()} active accounts with subscriptions")
for account in accounts:
try:
subscription = account.subscription
# Check if billing period has ended
if subscription.current_period_end and subscription.current_period_end.date() <= today:
logger.info(f"Resetting limits for account {account.id} ({account.name}) - "
f"period ended {subscription.current_period_end.date()}")
# Reset monthly limits
result = LimitService.reset_monthly_limits(account)
# Update subscription period
from dateutil.relativedelta import relativedelta
# Calculate new period based on billing cycle
plan = account.plan
if plan.billing_cycle == 'monthly':
new_period_start = subscription.current_period_end + timedelta(days=1)
new_period_end = new_period_start + relativedelta(months=1) - timedelta(days=1)
elif plan.billing_cycle == 'annual':
new_period_start = subscription.current_period_end + timedelta(days=1)
new_period_end = new_period_start + relativedelta(years=1) - timedelta(days=1)
else:
# Default to monthly
new_period_start = subscription.current_period_end + timedelta(days=1)
new_period_end = new_period_start + relativedelta(months=1) - timedelta(days=1)
# Update subscription
subscription.current_period_start = new_period_start
subscription.current_period_end = new_period_end
subscription.save(update_fields=['current_period_start', 'current_period_end'])
reset_count += 1
logger.info(f"Reset complete for account {account.id}: "
f"New period {new_period_start} to {new_period_end}")
except Exception as e:
error_count += 1
logger.error(f"Error resetting limits for account {account.id}: {str(e)}", exc_info=True)
logger.info(f"Monthly plan limits reset task complete: "
f"{reset_count} accounts reset, {error_count} errors")
return {
'reset_count': reset_count,
'error_count': error_count,
'total_accounts': accounts.count(),
}
@shared_task(name='check_approaching_limits')
def check_approaching_limits(threshold_percentage=80):
"""
Check for accounts approaching their plan limits and send notifications.
Args:
threshold_percentage: Percentage at which to trigger warning (default 80%)
This task should run daily.
It checks both hard and monthly limits and sends warnings when usage exceeds threshold.
"""
logger.info(f"Starting limit warning check task (threshold: {threshold_percentage}%)")
warning_count = 0
# Find all active accounts
accounts = Account.objects.filter(status='active').select_related('plan')
for account in accounts:
try:
if not account.plan:
continue
# Get usage summary
summary = LimitService.get_usage_summary(account)
warnings = []
# Check hard limits
for limit_type, data in summary.get('hard_limits', {}).items():
if data['percentage_used'] >= threshold_percentage:
warnings.append({
'type': 'hard',
'limit_type': limit_type,
'display_name': data['display_name'],
'current': data['current'],
'limit': data['limit'],
'percentage': data['percentage_used'],
})
# Check monthly limits
for limit_type, data in summary.get('monthly_limits', {}).items():
if data['percentage_used'] >= threshold_percentage:
warnings.append({
'type': 'monthly',
'limit_type': limit_type,
'display_name': data['display_name'],
'current': data['current'],
'limit': data['limit'],
'percentage': data['percentage_used'],
'resets_in_days': summary.get('days_until_reset', 0),
})
if warnings:
warning_count += 1
logger.info(f"Account {account.id} ({account.name}) has {len(warnings)} limit warnings")
# TODO: Send email notification
# from igny8_core.business.billing.services.email_service import send_limit_warning_email
# send_limit_warning_email(account, warnings)
except Exception as e:
logger.error(f"Error checking limits for account {account.id}: {str(e)}", exc_info=True)
logger.info(f"Limit warning check complete: {warning_count} accounts with warnings")
return {
'warning_count': warning_count,
'total_accounts': accounts.count(),
}

View File

@@ -0,0 +1,137 @@
"""
Word Counter Utility
Standardized word counting from HTML content
Single source of truth for Content.word_count calculation
"""
import re
import logging
logger = logging.getLogger(__name__)
# Try to import BeautifulSoup, fallback to regex if not available
try:
from bs4 import BeautifulSoup
HAS_BEAUTIFULSOUP = True
except ImportError:
HAS_BEAUTIFULSOUP = False
logger.warning("BeautifulSoup4 not available, using regex fallback for word counting")
def calculate_word_count(html_content: str) -> int:
"""
Calculate word count from HTML content by stripping tags and counting words.
This is the SINGLE SOURCE OF TRUTH for word counting in IGNY8.
All limit tracking and billing should use Content.word_count which is calculated using this function.
Args:
html_content: HTML string to count words from
Returns:
int: Number of words in the content
Examples:
>>> calculate_word_count("<p>Hello world</p>")
2
>>> calculate_word_count("<h1>Title</h1><p>This is a paragraph.</p>")
5
>>> calculate_word_count("")
0
>>> calculate_word_count(None)
0
"""
# Handle None or empty content
if not html_content or not isinstance(html_content, str):
return 0
html_content = html_content.strip()
if not html_content:
return 0
try:
# Use BeautifulSoup if available (more accurate)
if HAS_BEAUTIFULSOUP:
soup = BeautifulSoup(html_content, 'html.parser')
# Get text, removing all HTML tags
text = soup.get_text(separator=' ', strip=True)
else:
# Fallback to regex (less accurate but functional)
# Remove all HTML tags
text = re.sub(r'<[^>]+>', ' ', html_content)
# Remove extra whitespace
text = ' '.join(text.split())
# Count words (split on whitespace)
if not text:
return 0
words = text.split()
word_count = len(words)
logger.debug(f"Calculated word count: {word_count} from {len(html_content)} chars of HTML")
return word_count
except Exception as e:
logger.error(f"Error calculating word count: {e}", exc_info=True)
# Return 0 on error rather than failing
return 0
def format_word_count(word_count: int) -> str:
"""
Format word count for display (e.g., 1000 -> "1K", 100000 -> "100K")
Args:
word_count: Number of words
Returns:
str: Formatted word count
Examples:
>>> format_word_count(500)
'500'
>>> format_word_count(1500)
'1.5K'
>>> format_word_count(100000)
'100K'
>>> format_word_count(1500000)
'1.5M'
"""
if word_count >= 1000000:
return f"{word_count / 1000000:.1f}M"
elif word_count >= 1000:
# Show .5K if not a whole number, otherwise just show K
result = word_count / 1000
if result == int(result):
return f"{int(result)}K"
return f"{result:.1f}K"
return str(word_count)
def validate_word_count_limit(current_words: int, requested_words: int, limit: int) -> dict:
"""
Validate if requested word generation would exceed limit.
Args:
current_words: Current word count used in period
requested_words: Words being requested
limit: Maximum words allowed
Returns:
dict with:
- allowed (bool): Whether operation is allowed
- remaining (int): Remaining words available
- would_exceed_by (int): How many words over limit if not allowed
"""
remaining = max(0, limit - current_words)
allowed = current_words + requested_words <= limit
would_exceed_by = max(0, (current_words + requested_words) - limit)
return {
'allowed': allowed,
'remaining': remaining,
'would_exceed_by': would_exceed_by,
'current': current_words,
'requested': requested_words,
'limit': limit,
}