payemnt billing and credits refactoring

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-20 07:39:51 +00:00
parent a97c72640a
commit bc50b022f1
34 changed files with 3028 additions and 307 deletions

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.10 on 2026-01-20 06:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0034_add_user_phone'),
]
operations = [
migrations.AddField(
model_name='account',
name='bonus_credits',
field=models.IntegerField(default=0, help_text='Purchased/bonus credits (never expire, never reset)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='historicalaccount',
name='bonus_credits',
field=models.IntegerField(default=0, help_text='Purchased/bonus credits (never expire, never reset)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='account',
name='credits',
field=models.IntegerField(default=0, help_text='Plan credits (reset on renewal)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='historicalaccount',
name='credits',
field=models.IntegerField(default=0, help_text='Plan credits (reset on renewal)', validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -83,7 +83,8 @@ class Account(SoftDeletableModel):
)
stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Plan credits (reset on renewal)")
bonus_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Purchased/bonus credits (never expire, never reset)")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
payment_method = models.CharField(
max_length=30,
@@ -143,6 +144,11 @@ class Account(SoftDeletableModel):
def __str__(self):
return self.name
@property
def total_credits(self):
"""Total available credits (plan + bonus). Use this for balance checks."""
return self.credits + self.bonus_credits
@property
def default_payment_method(self):
"""Get default payment method from AccountPaymentMethod table"""

View File

@@ -41,6 +41,11 @@ class AccountSerializer(serializers.ModelSerializer):
plan = PlanSerializer(read_only=True)
plan_id = serializers.PrimaryKeyRelatedField(queryset=Plan.objects.filter(is_active=True), write_only=True, source='plan', required=False)
subscription = SubscriptionSerializer(read_only=True, allow_null=True)
total_credits = serializers.SerializerMethodField()
def get_total_credits(self, obj):
"""Return total available credits (plan + bonus)."""
return obj.credits + obj.bonus_credits
def validate_plan_id(self, value):
"""Validate plan_id is provided during creation."""
@@ -52,7 +57,7 @@ class AccountSerializer(serializers.ModelSerializer):
model = Account
fields = [
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
'credits', 'status', 'payment_method',
'credits', 'bonus_credits', 'total_credits', 'status', 'payment_method',
'subscription', 'billing_country',
'account_timezone', 'timezone_mode', 'timezone_offset',
'created_at'

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-01-20 06:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('automation', '0012_add_publishing_image_defaults'),
]
operations = [
migrations.RenameIndex(
model_name='automationconfig',
new_name='igny8_autom_test_mo_f43497_idx',
old_name='automation__test_mo_idx',
),
migrations.AlterField(
model_name='automationrun',
name='trigger_type',
field=models.CharField(choices=[('manual', 'Manual'), ('scheduled', 'Scheduled'), ('test', 'Test')], max_length=20),
),
]

View File

@@ -19,7 +19,7 @@ from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.services.invoice_service import InvoiceService
from igny8_core.business.billing.models import (
CreditTransaction, Invoice, Payment, CreditPackage,
AccountPaymentMethod, PaymentMethodConfig
AccountPaymentMethod, PaymentMethodConfig, WebhookEvent
)
from igny8_core.modules.billing.serializers import PaymentMethodConfigSerializer, PaymentConfirmationSerializer
import logging
@@ -151,6 +151,27 @@ class BillingViewSet(viewsets.GenericViewSet):
}
)
# Log to WebhookEvent for unified payment logs
WebhookEvent.record_event(
event_id=f'bt-confirm-{external_payment_id}-{timezone.now().timestamp()}',
provider='bank_transfer',
event_type='payment.confirmed',
payload={
'external_payment_id': external_payment_id,
'account_id': account.id,
'subscription_id': subscription.id,
'amount': str(amount),
'currency': 'USD',
'payer_name': payer_name,
'proof_url': proof_url if proof_url else '',
'period_months': period_months,
'confirmed_by': request.user.email,
'plan_name': account.plan.name if account.plan else None,
'credits_added': monthly_credits,
},
processed=True
)
logger.info(
f'Bank transfer confirmed for account {account.id}: '
f'{external_payment_id}, {amount}, {monthly_credits} credits added'
@@ -307,6 +328,26 @@ class BillingViewSet(viewsets.GenericViewSet):
metadata={'proof_url': proof_url, 'submitted_by': request.user.email} if proof_url else {'submitted_by': request.user.email}
)
# Log to WebhookEvent for unified payment logs
WebhookEvent.record_event(
event_id=f'{payment_method}-submit-{payment.id}-{timezone.now().timestamp()}',
provider=payment_method, # 'bank_transfer', 'local_wallet', 'manual'
event_type='payment.submitted',
payload={
'payment_id': payment.id,
'invoice_id': invoice.id,
'invoice_number': invoice.invoice_number,
'account_id': request.account.id,
'amount': str(amount),
'currency': invoice.currency,
'manual_reference': manual_reference,
'manual_notes': manual_notes,
'proof_url': proof_url if proof_url else '',
'submitted_by': request.user.email,
},
processed=False # Will be marked processed when approved
)
logger.info(
f'Payment confirmation submitted: Payment {payment.id}, '
f'Invoice {invoice.invoice_number}, Account {request.account.id}, '
@@ -442,52 +483,65 @@ class BillingViewSet(viewsets.GenericViewSet):
invoice.paid_at = timezone.now()
invoice.save(update_fields=['status', 'paid_at'])
# 3. Update Subscription
if subscription:
from igny8_core.business.billing.services.invoice_service import InvoiceService
from igny8_core.business.billing.models import CreditPackage
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
# 3. Update Subscription (subscription invoices only)
if invoice_type == 'subscription' and subscription:
subscription.status = 'active'
subscription.external_payment_id = payment.manual_reference
subscription.save(update_fields=['status', 'external_payment_id'])
# 4. Update Account
account.status = 'active'
account.save(update_fields=['status'])
# 5. Add Credits (if subscription has plan)
# 4. Update Account status (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save(update_fields=['status'])
# 5. Add/Reset Credits based on invoice type
credits_added = 0
try:
if subscription and subscription.plan and subscription.plan.included_credits > 0:
credits_added = subscription.plan.included_credits
# Use CreditService to add credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='subscription',
description=f'{subscription.plan.name} plan credits - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id,
'invoice_id': invoice.id,
'payment_id': payment.id,
'plan_id': subscription.plan.id,
'approved_by': request.user.email
}
)
elif account and account.plan and account.plan.included_credits > 0:
# Fallback: use account plan if subscription not found
credits_added = account.plan.included_credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='subscription',
description=f'{account.plan.name} plan credits - Invoice {invoice.invoice_number}',
metadata={
'invoice_id': invoice.id,
'payment_id': payment.id,
'plan_id': account.plan.id,
'approved_by': request.user.email,
'fallback': 'account_plan'
}
)
if invoice_type == 'credit_package':
credit_package_id = invoice.metadata.get('credit_package_id') if invoice and invoice.metadata else None
if credit_package_id:
package = CreditPackage.objects.get(id=credit_package_id)
credits_added = package.credits
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
CreditService.add_bonus_credits(
account=account,
amount=credits_added,
description=f'Credit package: {package.name} ({credits_added} bonus credits) - Invoice {invoice.invoice_number}',
metadata={
'invoice_id': invoice.id,
'payment_id': payment.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email,
}
)
else:
raise Exception('Credit package ID missing on invoice metadata')
elif invoice_type == 'subscription':
plan = None
if subscription and subscription.plan:
plan = subscription.plan
elif account and account.plan:
plan = account.plan
if plan and plan.included_credits > 0:
credits_added = plan.included_credits
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_added,
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': payment.id,
'plan_id': plan.id,
'approved_by': request.user.email
}
)
except Exception as credit_error:
# Rollback payment approval if credit addition fails
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
@@ -495,7 +549,7 @@ class BillingViewSet(viewsets.GenericViewSet):
logger.info(
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
f'Account {account.id} activated, {credits_added} credits added'
f'Account {account.id}, invoice_type={invoice_type}, credits_added={credits_added}'
)
# Send activation email to user
@@ -513,12 +567,13 @@ class BillingViewSet(viewsets.GenericViewSet):
'account_id': account.id,
'account_status': account.status,
'subscription_status': subscription.status if subscription else None,
'invoice_type': invoice_type,
'credits_added': credits_added,
'total_credits': account.credits,
'approved_by': request.user.email,
'approved_at': payment.approved_at.isoformat()
},
message='Payment approved successfully. Account activated.',
message='Payment approved successfully.',
request=request
)
@@ -651,6 +706,8 @@ class InvoiceViewSet(AccountModelViewSet):
'id': invoice.id,
'invoice_number': invoice.invoice_number,
'status': invoice.status,
'invoice_type': invoice.invoice_type,
'credit_package_id': invoice.metadata.get('credit_package_id') if invoice.metadata else None,
'total': str(invoice.total), # Alias for compatibility
'total_amount': str(invoice.total),
'subtotal': str(invoice.subtotal),
@@ -659,6 +716,8 @@ class InvoiceViewSet(AccountModelViewSet):
'invoice_date': invoice.invoice_date.isoformat(),
'due_date': invoice.due_date.isoformat(),
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
'expires_at': invoice.expires_at.isoformat() if invoice.expires_at else None,
'void_reason': invoice.void_reason,
'line_items': invoice.line_items,
'billing_email': invoice.billing_email,
'notes': invoice.notes,
@@ -732,6 +791,66 @@ class InvoiceViewSet(AccountModelViewSet):
logger.error(f'PDF generation failed for invoice {pk}: {str(e)}', exc_info=True)
return error_response(error=f'Failed to generate PDF: {str(e)}', status_code=500, request=request)
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
"""Cancel a pending credit invoice (user action)."""
from django.db import transaction
from django.utils import timezone
from igny8_core.business.billing.services.invoice_service import InvoiceService
try:
invoice = self.get_queryset().select_related('account').get(pk=pk)
invoice_type = InvoiceService.get_invoice_type(invoice)
if invoice_type != 'credit_package':
return error_response(
error='Only credit package invoices can be cancelled.',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if invoice.status != 'pending':
return error_response(
error=f'Invoice is not pending (current status: {invoice.status})',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
with transaction.atomic():
invoice.status = 'void'
invoice.void_reason = 'user_cancelled'
invoice.save(update_fields=['status', 'void_reason', 'updated_at'])
Payment.objects.filter(
invoice=invoice,
status='pending_approval'
).update(
status='failed',
failed_at=timezone.now(),
failure_reason='Invoice cancelled by user'
)
try:
from igny8_core.business.billing.services.email_service import BillingEmailService
BillingEmailService.send_credit_invoice_cancelled_email(invoice)
except Exception as e:
logger.error(f'Failed to send credit invoice cancellation email: {str(e)}')
return success_response(
data={'invoice_id': invoice.id, 'status': invoice.status},
message='Invoice cancelled successfully.',
request=request
)
except Invoice.DoesNotExist:
return error_response(error='Invoice not found', status_code=404, request=request)
except Exception as e:
logger.error(f'Error cancelling invoice {pk}: {str(e)}', exc_info=True)
return error_response(
error='Failed to cancel invoice. Please try again.',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class PaymentViewSet(AccountModelViewSet):
"""ViewSet for user-facing payments"""
@@ -942,8 +1061,9 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
# Check for existing pending invoice for this package
existing_pending = Invoice.objects.filter(
account=account,
status='pending',
metadata__credit_package_id=package.id
status='pending'
).filter(
Q(invoice_type='credit_package') | Q(metadata__credit_package_id=package.id)
).first()
if existing_pending:

View File

@@ -12,6 +12,9 @@ AUTO_APPROVE_PAYMENTS = getattr(settings, 'AUTO_APPROVE_PAYMENTS', False)
# Invoice due date offset (days)
INVOICE_DUE_DATE_OFFSET = getattr(settings, 'INVOICE_DUE_DATE_OFFSET', 7)
# Credit package invoice expiry (hours)
CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS = getattr(settings, 'CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS', 48)
# Grace period for payment (days)
PAYMENT_GRACE_PERIOD = getattr(settings, 'PAYMENT_GRACE_PERIOD', 7)

View File

@@ -0,0 +1,105 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from igny8_core.business.billing.models import Invoice, Payment, CreditTransaction, CreditPackage
from igny8_core.auth.models import Account
class Command(BaseCommand):
help = "Audit invoice/payment/credits for a purchase"
def add_arguments(self, parser):
parser.add_argument("--invoice-number", dest="invoice_number", help="Invoice number (e.g., INV-26010008)")
parser.add_argument("--invoice-id", dest="invoice_id", type=int, help="Invoice ID")
parser.add_argument("--payment-id", dest="payment_id", type=int, help="Payment ID")
parser.add_argument("--account-id", dest="account_id", type=int, help="Account ID (optional)")
def handle(self, *args, **options):
invoice_number = options.get("invoice_number")
invoice_id = options.get("invoice_id")
payment_id = options.get("payment_id")
account_id = options.get("account_id")
if not any([invoice_number, invoice_id, payment_id, account_id]):
self.stderr.write("Provide at least one filter: --invoice-number, --invoice-id, --payment-id, --account-id")
return
invoice_qs = Invoice.objects.all().select_related("account", "subscription", "subscription__plan")
payment_qs = Payment.objects.all().select_related("account", "invoice")
invoice = None
if invoice_number:
invoice = invoice_qs.filter(invoice_number=invoice_number).first()
elif invoice_id:
invoice = invoice_qs.filter(id=invoice_id).first()
elif payment_id:
payment = payment_qs.filter(id=payment_id).first()
invoice = payment.invoice if payment else None
elif account_id:
invoice = invoice_qs.filter(account_id=account_id).order_by("-created_at").first()
if not invoice:
self.stderr.write("No invoice found for the provided filter.")
return
account = invoice.account
invoice_type = invoice.invoice_type
credit_package_id = (invoice.metadata or {}).get("credit_package_id")
credit_package = None
if credit_package_id:
credit_package = CreditPackage.objects.filter(id=credit_package_id).first()
self.stdout.write("=== INVOICE ===")
self.stdout.write(f"Invoice: {invoice.invoice_number} (ID={invoice.id})")
self.stdout.write(f"Type: {invoice_type}")
self.stdout.write(f"Status: {invoice.status}")
self.stdout.write(f"Total: {invoice.total} {invoice.currency}")
self.stdout.write(f"Paid at: {invoice.paid_at}")
self.stdout.write(f"Expires at: {invoice.expires_at}")
self.stdout.write(f"Void reason: {invoice.void_reason}")
self.stdout.write(f"Account: {account.id} - {account.name}")
self.stdout.write(f"Account credits: {account.credits}")
if invoice.subscription:
plan = invoice.subscription.plan
self.stdout.write(f"Subscription: {invoice.subscription.id} (status={invoice.subscription.status})")
self.stdout.write(f"Plan: {plan.id if plan else None} - {plan.name if plan else None}")
if credit_package:
self.stdout.write(f"Credit Package: {credit_package.id} - {credit_package.name} ({credit_package.credits} credits)")
payments = payment_qs.filter(invoice_id=invoice.id).order_by("created_at")
self.stdout.write("\n=== PAYMENTS ===")
if not payments.exists():
self.stdout.write("No payments found for this invoice.")
else:
for pay in payments:
self.stdout.write(
f"Payment {pay.id}: status={pay.status}, method={pay.payment_method}, amount={pay.amount} {pay.currency}, processed_at={pay.processed_at}"
)
credit_transactions = CreditTransaction.objects.filter(account=account).order_by("-created_at")[:50]
related_transactions = CreditTransaction.objects.filter(
account=account
).filter(
metadata__invoice_id=invoice.id
).order_by("created_at")
self.stdout.write("\n=== CREDIT TRANSACTIONS (RELATED) ===")
if not related_transactions.exists():
self.stdout.write("No credit transactions linked to this invoice.")
else:
for tx in related_transactions:
self.stdout.write(
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
)
if not related_transactions.exists() and invoice_type == "credit_package" and invoice.status == "paid":
self.stdout.write("\n!!! WARNING: Paid credit invoice with no linked credit transaction.")
self.stdout.write("This indicates credits were not applied.")
self.stdout.write("\n=== RECENT CREDIT TRANSACTIONS (LAST 50) ===")
for tx in credit_transactions:
self.stdout.write(
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
)
self.stdout.write("\nAudit completed at: " + timezone.now().isoformat())

View File

@@ -336,6 +336,13 @@ class Invoice(AccountBaseModel):
Invoice for subscription or credit purchases
Tracks billing invoices with line items and payment status
"""
INVOICE_TYPE_CHOICES = [
('subscription', 'Subscription'),
('credit_package', 'Credit Package'),
('addon', 'Add-on'),
('custom', 'Custom'),
]
STATUS_CHOICES = [
('draft', 'Draft'),
('pending', 'Pending'),
@@ -345,6 +352,14 @@ class Invoice(AccountBaseModel):
]
invoice_number = models.CharField(max_length=50, unique=True, db_index=True)
# Type
invoice_type = models.CharField(
max_length=30,
choices=INVOICE_TYPE_CHOICES,
default='custom',
db_index=True
)
# Subscription relationship
subscription = models.ForeignKey(
@@ -369,6 +384,10 @@ class Invoice(AccountBaseModel):
invoice_date = models.DateField(db_index=True)
due_date = models.DateField()
paid_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
# Void metadata
void_reason = models.TextField(blank=True)
# Line items
line_items = models.JSONField(default=list, help_text="Invoice line items: [{description, amount, quantity}]")

View File

@@ -250,6 +250,8 @@ class CreditService:
For token-based operations, this is an estimate check only.
Actual deduction happens after AI call with real token usage.
Uses total_credits (plan + bonus) for the check.
Args:
account: Account instance
operation_type: Type of operation
@@ -274,9 +276,11 @@ class CreditService:
# Fallback to constants
required = CREDIT_COSTS.get(operation_type, 1)
if account.credits < required:
# Use total_credits (plan + bonus)
total_available = account.credits + account.bonus_credits
if total_available < required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
f"Insufficient credits. Required: {required}, Available: {total_available}"
)
return True
@@ -286,6 +290,8 @@ class CreditService:
Legacy method to check credits for a known amount.
Used internally by deduct_credits.
Uses total_credits (plan + bonus) for the check.
Args:
account: Account instance
amount: Required credits amount
@@ -293,9 +299,10 @@ class CreditService:
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < amount:
total_available = account.credits + account.bonus_credits
if total_available < amount:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {amount}, Available: {account.credits}"
f"Insufficient credits. Required: {amount}, Available: {total_available}"
)
return True
@@ -357,6 +364,12 @@ class CreditService:
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None, site=None):
"""
Deduct credits and log transaction.
CREDIT DEDUCTION ORDER:
1. Plan credits (account.credits) are consumed first
2. Bonus credits (account.bonus_credits) are consumed only when plan credits = 0
Bonus credits never expire and are not affected by plan renewal/reset.
Args:
account: Account instance
@@ -373,26 +386,43 @@ class CreditService:
site: Optional Site instance or site_id
Returns:
int: New credit balance
int: New total credit balance (plan + bonus)
"""
# Check sufficient credits (legacy: amount is already calculated)
CreditService.check_credits_legacy(account, amount)
# Check sufficient credits using total (plan + bonus)
total_available = account.credits + account.bonus_credits
if total_available < amount:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {amount}, Available: {total_available}"
)
# Store previous balance for low credits check
previous_balance = account.credits
previous_total = account.credits + account.bonus_credits
# Calculate how much to deduct from each pool
# Plan credits first, then bonus credits
from_plan = min(account.credits, amount)
from_bonus = amount - from_plan
# Deduct from plan credits first
account.credits -= from_plan
# Deduct remainder from bonus credits
if from_bonus > 0:
account.bonus_credits -= from_bonus
account.save(update_fields=['credits', 'bonus_credits'])
# Deduct from account.credits
account.credits -= amount
account.save(update_fields=['credits'])
# Create CreditTransaction
# Create CreditTransaction with details about source
CreditTransaction.objects.create(
account=account,
transaction_type='deduction',
amount=-amount, # Negative for deduction
balance_after=account.credits,
balance_after=account.credits + account.bonus_credits,
description=description,
metadata=metadata or {}
metadata={
**(metadata or {}),
'from_plan_credits': from_plan,
'from_bonus_credits': from_bonus,
}
)
# Convert site_id to Site instance if needed
@@ -419,13 +449,17 @@ class CreditService:
tokens_output=tokens_output,
related_object_type=related_object_type or '',
related_object_id=related_object_id,
metadata=metadata or {}
metadata={
**(metadata or {}),
'from_plan_credits': from_plan,
'from_bonus_credits': from_bonus,
}
)
# Check and send low credits warning if applicable
_check_low_credits_warning(account, previous_balance)
_check_low_credits_warning(account, previous_total)
return account.credits
return account.credits + account.bonus_credits
@staticmethod
@transaction.atomic
@@ -538,15 +572,62 @@ class CreditService:
return account.credits
@staticmethod
@transaction.atomic
def add_bonus_credits(account, amount, description, metadata=None):
"""
Add bonus credits from credit package purchase.
Bonus credits:
- Never expire
- Are NOT reset on subscription renewal
- Are consumed ONLY after plan credits are exhausted
Args:
account: Account instance
amount: Number of credits to add
description: Description of the transaction
metadata: Optional metadata dict
Returns:
int: New total credit balance (plan + bonus)
"""
# Add to bonus_credits (NOT plan credits)
account.bonus_credits += amount
account.save(update_fields=['bonus_credits'])
# Create CreditTransaction
CreditTransaction.objects.create(
account=account,
transaction_type='purchase', # Credit package purchase
amount=amount, # Positive for addition
balance_after=account.credits + account.bonus_credits, # Total balance
description=description,
metadata={
**(metadata or {}),
'credit_type': 'bonus',
'added_to_bonus_pool': True,
}
)
logger.info(
f"Bonus credits added: Account {account.id} - "
f"+{amount} bonus credits (new bonus balance: {account.bonus_credits})"
)
return account.credits + account.bonus_credits
@staticmethod
@transaction.atomic
def reset_credits_for_renewal(account, new_amount, description, metadata=None):
"""
Reset credits for subscription renewal (sets credits to new_amount instead of adding).
Reset PLAN credits for subscription renewal (sets credits to new_amount instead of adding).
This is used when a subscription renews - the credits are reset to the full
This is used when a subscription renews - the PLAN credits are reset to the full
plan amount, not added to existing balance.
IMPORTANT: Bonus credits are NOT affected by this reset.
Args:
account: Account instance
new_amount: Number of credits to set (plan's included_credits)
@@ -554,36 +635,37 @@ class CreditService:
metadata: Optional metadata dict
Returns:
int: New credit balance
int: New total credit balance (plan + bonus)
"""
old_balance = account.credits
old_plan_balance = account.credits
account.credits = new_amount
account.save(update_fields=['credits'])
account.save(update_fields=['credits']) # Only update plan credits
# Calculate the change for the transaction record
change_amount = new_amount - old_balance
change_amount = new_amount - old_plan_balance
# Create CreditTransaction - use 'subscription' type for renewal
CreditTransaction.objects.create(
account=account,
transaction_type='subscription', # Uses 'Subscription Renewal' display
amount=change_amount, # Can be positive or negative depending on usage
balance_after=account.credits,
balance_after=account.credits + account.bonus_credits, # Total balance
description=description,
metadata={
**(metadata or {}),
'reset_from': old_balance,
'reset_from': old_plan_balance,
'reset_to': new_amount,
'is_renewal_reset': True
'is_renewal_reset': True,
'bonus_credits_unchanged': account.bonus_credits,
}
)
logger.info(
f"Credits reset for renewal: Account {account.id} - "
f"from {old_balance} to {new_amount} (change: {change_amount})"
f"Plan credits reset for renewal: Account {account.id} - "
f"Plan: {old_plan_balance} {new_amount}, Bonus: {account.bonus_credits} (unchanged)"
)
return account.credits
return account.credits + account.bonus_credits
@staticmethod
@transaction.atomic

View File

@@ -1127,6 +1127,147 @@ To view and pay your invoice:
If you have any questions, please contact our support team.
Thank you,
The IGNY8 Team
""".strip(),
)
@staticmethod
def send_credit_invoice_expiring_email(invoice):
"""Notify user that a credit invoice will expire soon."""
service = get_email_service()
frontend_url = BillingEmailService._get_frontend_url()
account = invoice.account
context = {
'account_name': account.name,
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
'total_amount': invoice.total_amount,
'currency': invoice.currency or 'USD',
'expires_at': invoice.expires_at.strftime('%Y-%m-%d %H:%M') if invoice.expires_at else 'N/A',
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
'frontend_url': frontend_url,
}
subject = f"Invoice #{context['invoice_number']} expiring soon"
try:
result = service.send_transactional(
to=account.billing_email or account.owner.email,
subject=subject,
template='emails/credit_invoice_expiring.html',
context=context,
tags=['billing', 'invoice', 'expiring'],
)
logger.info(f'Credit invoice expiring email sent for Invoice {invoice.id}')
return result
except Exception as e:
logger.error(f'Failed to send credit invoice expiring email: {str(e)}')
return service.send_transactional(
to=account.billing_email or account.owner.email,
subject=subject,
text=f"""
Hi {account.name},
Your credit invoice #{context['invoice_number']} is expiring soon.
Amount: {context['currency']} {context['total_amount']}
Expires at: {context['expires_at']}
Complete payment here:
{context['invoice_url']}
Thank you,
The IGNY8 Team
""".strip(),
)
@staticmethod
def send_credit_invoice_expired_email(invoice):
"""Notify user that a credit invoice has expired."""
service = get_email_service()
frontend_url = BillingEmailService._get_frontend_url()
account = invoice.account
context = {
'account_name': account.name,
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
'frontend_url': frontend_url,
}
subject = f"Invoice #{context['invoice_number']} expired"
try:
result = service.send_transactional(
to=account.billing_email or account.owner.email,
subject=subject,
template='emails/credit_invoice_expired.html',
context=context,
tags=['billing', 'invoice', 'expired'],
)
logger.info(f'Credit invoice expired email sent for Invoice {invoice.id}')
return result
except Exception as e:
logger.error(f'Failed to send credit invoice expired email: {str(e)}')
return service.send_transactional(
to=account.billing_email or account.owner.email,
subject=subject,
text=f"""
Hi {account.name},
Your credit invoice #{context['invoice_number']} has expired and was voided.
You can create a new credit purchase anytime from your billing page:
{context['invoice_url']}
Thank you,
The IGNY8 Team
""".strip(),
)
@staticmethod
def send_credit_invoice_cancelled_email(invoice):
"""Notify user that a credit invoice was cancelled."""
service = get_email_service()
frontend_url = BillingEmailService._get_frontend_url()
account = invoice.account
context = {
'account_name': account.name,
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
'frontend_url': frontend_url,
}
subject = f"Invoice #{context['invoice_number']} cancelled"
try:
result = service.send_transactional(
to=account.billing_email or account.owner.email,
subject=subject,
template='emails/credit_invoice_cancelled.html',
context=context,
tags=['billing', 'invoice', 'cancelled'],
)
logger.info(f'Credit invoice cancelled email sent for Invoice {invoice.id}')
return result
except Exception as e:
logger.error(f'Failed to send credit invoice cancelled email: {str(e)}')
return service.send_transactional(
to=account.billing_email or account.owner.email,
subject=subject,
text=f"""
Hi {account.name},
Your credit invoice #{context['invoice_number']} was cancelled.
You can create a new credit purchase anytime from your billing page:
{context['invoice_url']}
Thank you,
The IGNY8 Team
""".strip(),
@@ -1222,6 +1363,60 @@ Please update your payment method to continue your subscription:
If you need assistance, please contact our support team.
Thank you,
The IGNY8 Team
""".strip(),
)
@staticmethod
def send_credits_reset_warning(account, subscription, previous_credits=0):
"""
Send warning email when credits are reset to 0 due to non-payment after 24 hours.
"""
service = get_email_service()
frontend_url = BillingEmailService._get_frontend_url()
context = {
'account_name': account.name,
'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A',
'previous_credits': previous_credits,
'frontend_url': frontend_url,
'billing_url': f'{frontend_url}/account/plans',
}
try:
result = service.send_transactional(
to=account.billing_email or account.owner.email,
subject='Credits Reset - Payment Required to Restore',
template='emails/credits_reset_warning.html',
context=context,
tags=['billing', 'credits-reset'],
)
logger.info(f'Credits reset warning email sent for account {account.id}')
return result
except Exception as e:
logger.error(f'Failed to send credits reset warning email: {str(e)}')
return service.send_transactional(
to=account.billing_email or account.owner.email,
subject='Credits Reset - Payment Required to Restore',
text=f"""
Hi {account.name},
Your plan credits have been reset to 0 because your subscription renewal payment was not received within 24 hours of your renewal date.
Previous credits: {previous_credits}
Current credits: 0
Plan: {context['plan_name']}
Your bonus credits (if any) remain unaffected.
To restore your credits and continue using IGNY8:
{context['billing_url']}
Please complete your payment as soon as possible to restore your credits and continue enjoying uninterrupted service.
If you have any questions or need assistance, please contact our support team.
Thank you,
The IGNY8 Team
""".strip(),

View File

@@ -13,6 +13,30 @@ from ....auth.models import Account, Subscription
class InvoiceService:
"""Service for managing invoices"""
@staticmethod
def get_invoice_type(invoice: Invoice) -> str:
"""Determine invoice type using explicit field with legacy fallbacks."""
if getattr(invoice, 'invoice_type', None):
return invoice.invoice_type
if invoice.metadata and invoice.metadata.get('credit_package_id'):
return 'credit_package'
if getattr(invoice, 'subscription_id', None):
return 'subscription'
return 'custom'
@staticmethod
def get_credit_package_from_invoice(invoice: Invoice) -> Optional[CreditPackage]:
"""Resolve credit package from invoice metadata when present."""
credit_package_id = None
if invoice.metadata:
credit_package_id = invoice.metadata.get('credit_package_id')
if not credit_package_id:
return None
try:
return CreditPackage.objects.get(id=credit_package_id)
except CreditPackage.DoesNotExist:
return None
@staticmethod
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
@@ -126,6 +150,7 @@ class InvoiceService:
account=account,
subscription=subscription, # Set FK directly
invoice_number=InvoiceService.generate_invoice_number(account),
invoice_type='subscription',
status='pending',
currency=currency,
invoice_date=invoice_date,
@@ -174,6 +199,7 @@ class InvoiceService:
# ALWAYS use USD for invoices (simplified accounting)
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
from igny8_core.business.billing.config import CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS
currency = 'USD'
usd_price = float(credit_package.price)
@@ -192,10 +218,12 @@ class InvoiceService:
invoice = Invoice.objects.create(
account=account,
invoice_number=InvoiceService.generate_invoice_number(account),
invoice_type='credit_package',
status='pending',
currency=currency,
invoice_date=invoice_date,
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
expires_at=timezone.now() + timedelta(hours=CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS),
metadata={
'billing_snapshot': {
'email': billing_email,
@@ -255,6 +283,7 @@ class InvoiceService:
invoice = Invoice.objects.create(
account=account,
invoice_number=InvoiceService.generate_invoice_number(account),
invoice_type='custom',
status='draft',
currency='USD',
notes=notes,

View File

@@ -1,6 +1,7 @@
"""
Payment Service - Handles payment processing across multiple gateways
"""
import logging
from decimal import Decimal
from typing import Optional, Dict, Any
from django.db import transaction
@@ -9,6 +10,9 @@ from django.utils import timezone
from ..models import Payment, Invoice, CreditPackage, PaymentMethodConfig, CreditTransaction
from ....auth.models import Account
# Use dedicated payment logger (logs to /logs/billing-logs/payments.log)
logger = logging.getLogger('payments')
class PaymentService:
"""Service for processing payments across multiple gateways"""
@@ -24,6 +28,7 @@ class PaymentService:
"""
Create payment record for Stripe transaction
"""
logger.info(f"Creating Stripe payment for invoice {invoice.invoice_number}, PI: {stripe_payment_intent_id}")
payment = Payment.objects.create(
account=invoice.account,
invoice=invoice,
@@ -35,7 +40,7 @@ class PaymentService:
stripe_charge_id=stripe_charge_id,
metadata=metadata or {}
)
logger.info(f"Created Stripe payment {payment.id} for account {invoice.account_id}")
return payment
@staticmethod
@@ -48,6 +53,7 @@ class PaymentService:
"""
Create payment record for PayPal transaction
"""
logger.info(f"Creating PayPal payment for invoice {invoice.invoice_number}, Order: {paypal_order_id}")
payment = Payment.objects.create(
account=invoice.account,
invoice=invoice,
@@ -182,16 +188,23 @@ class PaymentService:
transaction_id=payment.transaction_reference
)
# If payment is for credit package, add credits
if payment.metadata.get('credit_package_id'):
PaymentService._add_credits_for_payment(payment)
# If account is inactive/suspended/trial, activate it on successful payment
# Apply fulfillment based on invoice type
try:
account = payment.account
if account and account.status != 'active':
account.status = 'active'
account.save(update_fields=['status', 'updated_at'])
invoice = payment.invoice
from .invoice_service import InvoiceService
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
if invoice_type == 'credit_package':
PaymentService._add_credits_for_payment(payment)
elif invoice_type == 'subscription':
if invoice and getattr(invoice, 'subscription', None):
invoice.subscription.status = 'active'
invoice.subscription.save(update_fields=['status', 'updated_at'])
if account and account.status != 'active':
account.status = 'active'
account.save(update_fields=['status', 'updated_at'])
except Exception:
# Do not block payment approval if status update fails
pass
@@ -222,9 +235,16 @@ class PaymentService:
@staticmethod
def _add_credits_for_payment(payment: Payment) -> None:
"""
Add credits to account after successful payment
Add bonus credits to account after successful credit package payment.
Bonus credits:
- Never expire
- Are NOT reset on subscription renewal
- Are consumed ONLY after plan credits are exhausted
"""
credit_package_id = payment.metadata.get('credit_package_id')
if not credit_package_id and payment.invoice and payment.invoice.metadata:
credit_package_id = payment.invoice.metadata.get('credit_package_id')
if not credit_package_id:
return
@@ -233,18 +253,13 @@ class PaymentService:
except CreditPackage.DoesNotExist:
return
# Update account balance
account: Account = payment.account
account.credits = (account.credits or 0) + credit_package.credits
account.save(update_fields=['credits', 'updated_at'])
from igny8_core.business.billing.services.credit_service import CreditService
# Create credit transaction
CreditTransaction.objects.create(
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
CreditService.add_bonus_credits(
account=payment.account,
amount=credit_package.credits,
transaction_type='purchase',
description=f"Purchased {credit_package.name}",
reference_id=str(payment.id),
description=f"Purchased {credit_package.name} ({credit_package.credits} bonus credits)",
metadata={
'payment_id': payment.id,
'credit_package_id': credit_package_id,

View File

@@ -0,0 +1,12 @@
# Import tasks so they can be discovered by Celery autodiscover
from .invoice_lifecycle import (
send_credit_invoice_expiry_reminders,
void_expired_credit_invoices,
)
from .subscription_renewal import (
send_renewal_notices,
process_subscription_renewals,
renew_subscription,
send_invoice_reminders,
check_expired_renewals,
)

View File

@@ -0,0 +1,79 @@
"""
Invoice lifecycle tasks (expiry/reminders)
"""
from datetime import timedelta
import logging
from celery import shared_task
from django.utils import timezone
from igny8_core.business.billing.models import Invoice
from igny8_core.business.billing.services.email_service import BillingEmailService
logger = logging.getLogger(__name__)
EXPIRY_REMINDER_HOURS = 24
@shared_task(name='billing.send_credit_invoice_expiry_reminders')
def send_credit_invoice_expiry_reminders():
"""Send reminder emails for credit package invoices expiring soon."""
now = timezone.now()
threshold = now + timedelta(hours=EXPIRY_REMINDER_HOURS)
invoices = Invoice.objects.filter(
status='pending',
invoice_type='credit_package',
expires_at__isnull=False,
expires_at__lte=threshold,
expires_at__gt=now,
).select_related('account')
sent = 0
for invoice in invoices:
metadata = invoice.metadata or {}
if metadata.get('expiry_reminder_sent'):
continue
try:
BillingEmailService.send_credit_invoice_expiring_email(invoice)
metadata['expiry_reminder_sent'] = True
metadata['expiry_reminder_sent_at'] = now.isoformat()
invoice.metadata = metadata
invoice.save(update_fields=['metadata'])
sent += 1
except Exception as e:
logger.exception(f"Failed to send expiry reminder for invoice {invoice.id}: {str(e)}")
if sent:
logger.info(f"Sent {sent} credit invoice expiry reminders")
@shared_task(name='billing.void_expired_credit_invoices')
def void_expired_credit_invoices():
"""Void expired credit package invoices and notify users."""
now = timezone.now()
invoices = Invoice.objects.filter(
status='pending',
invoice_type='credit_package',
expires_at__isnull=False,
expires_at__lte=now,
).select_related('account')
voided = 0
for invoice in invoices:
try:
invoice.status = 'void'
invoice.void_reason = 'expired'
invoice.save(update_fields=['status', 'void_reason', 'updated_at'])
try:
BillingEmailService.send_credit_invoice_expired_email(invoice)
except Exception:
pass
voided += 1
except Exception as e:
logger.exception(f"Failed to void invoice {invoice.id}: {str(e)}")
if voided:
logger.info(f"Voided {voided} expired credit invoices")

View File

@@ -1,66 +1,135 @@
"""
Subscription renewal tasks
Handles automatic subscription renewals with Celery
Workflow by Payment Method:
1. Stripe/PayPal (auto-pay): No advance notice (industry standard)
- Invoice created on renewal day, auto-charged immediately
- If payment fails: retry email sent, user can Pay Now
- Credits reset on payment success
2. Bank Transfer (manual):
- Day -3: Invoice created + email sent
- Day 0: Renewal day reminder (if unpaid)
- Day +1: Urgent reminder + credits reset to 0 (if unpaid)
- Day +7: Subscription expired
"""
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from celery import shared_task
from igny8_core.business.billing.models import Subscription, Invoice, Payment
from igny8_core.auth.models import Subscription
from igny8_core.business.billing.models import Invoice, Payment
from igny8_core.business.billing.services.invoice_service import InvoiceService
from igny8_core.business.billing.services.email_service import BillingEmailService
from igny8_core.business.billing.config import SUBSCRIPTION_RENEWAL_NOTICE_DAYS
import logging
logger = logging.getLogger(__name__)
# Grace period in days for manual payment before subscription expires
RENEWAL_GRACE_PERIOD_DAYS = 7
# Days between invoice reminder emails
INVOICE_REMINDER_INTERVAL_DAYS = 3
# Days before renewal to generate invoice for bank transfer (manual payment)
BANK_TRANSFER_ADVANCE_INVOICE_DAYS = 3
# Hours after renewal to reset credits if payment not received (for bank transfer)
CREDIT_RESET_DELAY_HOURS = 24
@shared_task(name='billing.send_renewal_notices')
def send_renewal_notices():
@shared_task(name='billing.create_bank_transfer_invoices')
def create_bank_transfer_invoices():
"""
Send renewal notice emails to subscribers
Run daily to check subscriptions expiring soon
"""
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_DAYS)
Create invoices for bank transfer users 3 days before renewal.
Run daily at 09:00.
Only for manual payment accounts (bank transfer, local wallet).
Stripe/PayPal users get auto-charged on renewal day - no advance invoice.
"""
now = timezone.now()
# Generate invoices for bank transfer users (3 days before renewal)
invoice_date = now.date() + timedelta(days=BANK_TRANSFER_ADVANCE_INVOICE_DAYS)
# Get active subscriptions expiring on notice_date
subscriptions = Subscription.objects.filter(
status='active',
current_period_end__date=notice_date
).select_related('account', 'plan', 'account__owner')
current_period_end__date=invoice_date
).select_related('account', 'plan')
created_count = 0
for subscription in subscriptions:
# Check if notice already sent
if subscription.metadata.get('renewal_notice_sent'):
# Only for bank transfer / manual payment users
if not _is_manual_payment_account(subscription):
continue
# Skip if advance invoice already created
if subscription.metadata.get('advance_invoice_created'):
continue
try:
BillingEmailService.send_subscription_renewal_notice(
subscription=subscription,
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
# Create advance invoice
invoice = InvoiceService.create_subscription_invoice(
account=subscription.account,
subscription=subscription
)
# Mark notice as sent
subscription.metadata['renewal_notice_sent'] = True
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
# Mark advance invoice created
subscription.metadata['advance_invoice_created'] = True
subscription.metadata['advance_invoice_id'] = invoice.id
subscription.metadata['advance_invoice_created_at'] = now.isoformat()
subscription.save(update_fields=['metadata'])
logger.info(f"Renewal notice sent for subscription {subscription.id}")
# Send invoice email
try:
BillingEmailService.send_invoice_email(
invoice,
is_reminder=False,
extra_context={
'days_until_renewal': BANK_TRANSFER_ADVANCE_INVOICE_DAYS,
'is_advance_invoice': True
}
)
except Exception as e:
logger.exception(f"Failed to send advance invoice email: {str(e)}")
created_count += 1
logger.info(f"Advance invoice created for bank transfer subscription {subscription.id}")
except Exception as e:
logger.exception(f"Failed to send renewal notice for subscription {subscription.id}: {str(e)}")
logger.exception(f"Failed to create advance invoice for subscription {subscription.id}: {str(e)}")
logger.info(f"Created {created_count} advance invoices for bank transfer renewals")
return created_count
def _is_manual_payment_account(subscription: Subscription) -> bool:
"""
Check if account uses manual payment (bank transfer, local wallet)
These accounts don't have automatic billing set up
"""
# Has no automatic payment method configured
if subscription.metadata.get('stripe_subscription_id'):
return False
if subscription.metadata.get('paypal_subscription_id'):
return False
# Check account's billing country (PK = bank transfer)
account = subscription.account
if hasattr(account, 'billing_country') and account.billing_country == 'PK':
return True
# Check payment method preference in metadata
if subscription.metadata.get('payment_method') in ['bank_transfer', 'local_wallet', 'manual']:
return True
return True # Default to manual if no auto-payment configured
@shared_task(name='billing.process_subscription_renewals')
def process_subscription_renewals():
"""
Process subscription renewals for subscriptions ending today
Run daily at midnight to renew subscriptions
Process subscription renewals for subscriptions ending today.
Run daily at 00:05.
For Stripe/PayPal: Create invoice, attempt auto-charge, restore credits on success
For Bank Transfer: Invoice already created 3 days ago, just send renewal day reminder
"""
today = timezone.now().date()
@@ -82,7 +151,12 @@ def process_subscription_renewals():
@shared_task(name='billing.renew_subscription')
def renew_subscription(subscription_id: int):
"""
Renew a specific subscription
Renew a specific subscription.
Key behavior changes:
- Credits are NOT reset at cycle end
- Credits only reset after payment confirmed (for successful renewals)
- Credits reset to 0 after 24 hours if payment not received
Args:
subscription_id: Subscription ID to renew
@@ -95,44 +169,91 @@ def renew_subscription(subscription_id: int):
return
with transaction.atomic():
# Create renewal invoice
invoice = InvoiceService.create_subscription_invoice(
account=subscription.account,
subscription=subscription
)
# Check if this is a manual payment account with advance invoice
is_manual = _is_manual_payment_account(subscription)
# Attempt automatic payment if payment method on file
payment_attempted = False
# Check if account has saved payment method for automatic billing
if subscription.metadata.get('stripe_subscription_id'):
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
elif subscription.metadata.get('paypal_subscription_id'):
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
if payment_attempted:
# Payment processing will handle subscription renewal
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
else:
# No automatic payment - send invoice for manual payment
# This handles all payment methods: bank_transfer, local_wallet, manual
logger.info(f"Manual payment required for subscription {subscription_id}")
if is_manual:
# For bank transfer: Invoice was created 3 days ago
# Just mark as pending renewal and send reminder
invoice_id = subscription.metadata.get('advance_invoice_id')
if invoice_id:
try:
invoice = Invoice.objects.get(id=invoice_id)
# Invoice already exists, check if paid
if invoice.status == 'paid':
# Payment received before renewal date - great!
_complete_renewal(subscription, invoice)
logger.info(f"Bank transfer subscription {subscription_id} renewed (advance payment received)")
return
except Invoice.DoesNotExist:
pass
# Mark subscription as pending renewal with grace period
# Not paid yet - mark as pending renewal
grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
credit_reset_time = timezone.now() + timedelta(hours=CREDIT_RESET_DELAY_HOURS)
subscription.status = 'pending_renewal'
subscription.metadata['renewal_invoice_id'] = invoice.id
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
subscription.metadata['credit_reset_scheduled_at'] = credit_reset_time.isoformat()
subscription.metadata['last_invoice_reminder_at'] = timezone.now().isoformat()
subscription.save(update_fields=['status', 'metadata'])
# Send invoice email for manual payment
try:
BillingEmailService.send_invoice_email(invoice, is_reminder=False)
logger.info(f"Invoice email sent for subscription {subscription_id}")
except Exception as e:
logger.exception(f"Failed to send invoice email for subscription {subscription_id}: {str(e)}")
# Send renewal day reminder
if invoice_id:
try:
invoice = Invoice.objects.get(id=invoice_id)
BillingEmailService.send_invoice_email(
invoice,
is_reminder=True,
extra_context={'is_renewal_day': True}
)
except Exception as e:
logger.exception(f"Failed to send renewal day reminder: {str(e)}")
logger.info(f"Bank transfer subscription {subscription_id} marked pending renewal")
else:
# For Stripe/PayPal: Create invoice and attempt auto-charge
invoice = InvoiceService.create_subscription_invoice(
account=subscription.account,
subscription=subscription
)
payment_attempted = False
if subscription.metadata.get('stripe_subscription_id'):
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
elif subscription.metadata.get('paypal_subscription_id'):
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
if payment_attempted:
# Payment processing will handle credit reset via webhook
subscription.metadata['renewal_invoice_id'] = invoice.id
subscription.save(update_fields=['metadata'])
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
else:
# Auto-payment failed - mark as pending with Pay Now option
grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
credit_reset_time = timezone.now() + timedelta(hours=CREDIT_RESET_DELAY_HOURS)
subscription.status = 'pending_renewal'
subscription.metadata['renewal_invoice_id'] = invoice.id
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
subscription.metadata['credit_reset_scheduled_at'] = credit_reset_time.isoformat()
subscription.metadata['auto_payment_failed'] = True
subscription.save(update_fields=['status', 'metadata'])
try:
BillingEmailService.send_invoice_email(
invoice,
is_reminder=False,
extra_context={'auto_payment_failed': True, 'show_pay_now': True}
)
except Exception as e:
logger.exception(f"Failed to send invoice email: {str(e)}")
logger.info(f"Auto-payment failed for subscription {subscription_id}, manual payment required")
# Clear renewal notice flag
if 'renewal_notice_sent' in subscription.metadata:
@@ -145,29 +266,114 @@ def renew_subscription(subscription_id: int):
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
@shared_task(name='billing.send_invoice_reminders')
def send_invoice_reminders():
def _complete_renewal(subscription: Subscription, invoice: Invoice):
"""
Send invoice reminder emails for pending renewals
Run daily to remind accounts with pending invoices
Complete a successful renewal - reset plan credits to full amount.
Called when payment is confirmed (either via webhook or manual approval).
"""
try:
from igny8_core.business.billing.services.credit_service import CreditService
if subscription.plan and (subscription.plan.included_credits or 0) > 0:
CreditService.reset_credits_for_renewal(
account=subscription.account,
new_amount=subscription.plan.included_credits,
description=f'Subscription renewed - {subscription.plan.name}',
metadata={
'subscription_id': subscription.id,
'plan_id': subscription.plan.id,
'invoice_id': invoice.id if invoice else None,
'reset_reason': 'renewal_payment_confirmed'
}
)
# Update subscription status and period
subscription.status = 'active'
subscription.current_period_start = subscription.current_period_end
subscription.current_period_end = subscription.current_period_end + timedelta(days=30) # or use plan billing_interval
# Clear renewal metadata
for key in ['renewal_invoice_id', 'renewal_required_at', 'grace_period_end',
'credit_reset_scheduled_at', 'advance_invoice_id', 'advance_invoice_created',
'auto_payment_failed']:
subscription.metadata.pop(key, None)
subscription.save()
logger.info(f"Subscription {subscription.id} renewal completed, credits reset to {subscription.plan.included_credits}")
except Exception as e:
logger.exception(f"Error completing renewal for subscription {subscription.id}: {str(e)}")
@shared_task(name='billing.send_renewal_day_reminders')
def send_renewal_day_reminders():
"""
Send Day 0 renewal reminder for bank transfer users with unpaid invoices.
Run daily at 10:00.
Only sends if:
- Subscription is pending_renewal
- Invoice still unpaid
- It's the renewal day (Day 0)
"""
now = timezone.now()
reminder_threshold = now - timedelta(days=INVOICE_REMINDER_INTERVAL_DAYS)
# Get subscriptions pending renewal
# Get subscriptions pending renewal (set to pending_renewal on Day 0 at 00:05)
subscriptions = Subscription.objects.filter(
status='pending_renewal'
).select_related('account', 'plan')
reminder_count = 0
for subscription in subscriptions:
# Check if enough time has passed since last reminder
last_reminder = subscription.metadata.get('last_invoice_reminder_at')
if last_reminder:
from datetime import datetime
last_reminder_dt = datetime.fromisoformat(last_reminder.replace('Z', '+00:00'))
if hasattr(last_reminder_dt, 'tzinfo') and last_reminder_dt.tzinfo is None:
last_reminder_dt = timezone.make_aware(last_reminder_dt)
if last_reminder_dt > reminder_threshold:
# Only send if renewal was today (Day 0)
renewal_required_at = subscription.metadata.get('renewal_required_at')
if not renewal_required_at:
continue
from datetime import datetime
renewal_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00'))
if hasattr(renewal_dt, 'tzinfo') and renewal_dt.tzinfo is None:
renewal_dt = timezone.make_aware(renewal_dt)
# Only send Day 0 reminder on the actual renewal day
if renewal_dt.date() != now.date():
continue
# Skip if Day 0 reminder already sent
if subscription.metadata.get('day0_reminder_sent'):
continue
# Get invoice
invoice_id = subscription.metadata.get('advance_invoice_id') or subscription.metadata.get('renewal_invoice_id')
if not invoice_id:
continue
try:
invoice = Invoice.objects.get(id=invoice_id)
if invoice.status == 'paid':
continue # Already paid
BillingEmailService.send_invoice_email(
invoice,
is_reminder=True,
extra_context={'is_renewal_day': True, 'urgency': 'high'}
)
subscription.metadata['day0_reminder_sent'] = True
subscription.metadata['day0_reminder_sent_at'] = now.isoformat()
subscription.save(update_fields=['metadata'])
reminder_count += 1
logger.info(f"Day 0 renewal reminder sent for subscription {subscription.id}")
except Invoice.DoesNotExist:
logger.warning(f"Invoice {invoice_id} not found for subscription {subscription.id}")
except Exception as e:
logger.exception(f"Failed to send Day 0 reminder for subscription {subscription.id}: {str(e)}")
logger.info(f"Sent {reminder_count} Day 0 renewal reminders")
return reminder_count
continue
# Get the renewal invoice
@@ -254,6 +460,117 @@ def check_expired_renewals():
if expired_count > 0:
logger.info(f"Expired {expired_count} subscriptions due to non-payment")
return expired_count
# Legacy task - kept for backward compatibility, but functionality moved to send_day_after_reminders
@shared_task(name='billing.reset_unpaid_renewal_credits')
def reset_unpaid_renewal_credits():
"""
DEPRECATED: Credit reset is now handled by send_day_after_reminders task.
This task is kept for backward compatibility during transition.
"""
logger.info("reset_unpaid_renewal_credits is deprecated - credit reset handled by send_day_after_reminders")
return 0
@shared_task(name='billing.send_day_after_reminders')
def send_day_after_reminders():
"""
Send Day +1 urgent reminders AND reset credits for still-unpaid invoices.
Run daily at 09:15.
Combined task that:
1. Sends urgent Day +1 reminder email for bank transfer users
2. Resets plan credits to 0 if not paid after 24 hours
"""
now = timezone.now()
subscriptions = Subscription.objects.filter(
status='pending_renewal'
).select_related('account', 'plan')
reminder_count = 0
reset_count = 0
for subscription in subscriptions:
renewal_required_at = subscription.metadata.get('renewal_required_at')
if not renewal_required_at:
continue
from datetime import datetime
renewal_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00'))
if hasattr(renewal_dt, 'tzinfo') and renewal_dt.tzinfo is None:
renewal_dt = timezone.make_aware(renewal_dt)
# Check if it's been about 1 day since renewal (Day +1)
time_since_renewal = now - renewal_dt
if time_since_renewal < timedelta(hours=23) or time_since_renewal > timedelta(hours=48):
continue
invoice_id = subscription.metadata.get('renewal_invoice_id') or subscription.metadata.get('advance_invoice_id')
# 1. Send Day +1 urgent reminder (if not sent)
if not subscription.metadata.get('day_after_reminder_sent') and invoice_id:
try:
invoice = Invoice.objects.get(id=invoice_id)
if invoice.status in ['pending', 'overdue']:
BillingEmailService.send_invoice_email(
invoice,
is_reminder=True,
extra_context={
'is_day_after_reminder': True,
'credits_reset_warning': True,
'urgent': True
}
)
subscription.metadata['day_after_reminder_sent'] = True
subscription.metadata['day_after_reminder_sent_at'] = now.isoformat()
subscription.save(update_fields=['metadata'])
reminder_count += 1
logger.info(f"Day +1 urgent reminder sent for subscription {subscription.id}")
except Invoice.DoesNotExist:
logger.warning(f"Invoice {invoice_id} not found for day-after reminder")
except Exception as e:
logger.exception(f"Failed to send day-after reminder for subscription {subscription.id}: {str(e)}")
# 2. Reset credits to 0 (if not already reset)
if not subscription.metadata.get('credits_reset_for_nonpayment'):
try:
from igny8_core.business.billing.services.credit_service import CreditService
# Reset plan credits to 0 (bonus credits NOT affected)
current_credits = subscription.account.credits
if current_credits > 0:
CreditService.reset_credits_for_renewal(
account=subscription.account,
new_amount=0,
description='Credits reset due to unpaid renewal (Day +1)',
metadata={
'subscription_id': subscription.id,
'plan_id': subscription.plan.id if subscription.plan else None,
'reset_reason': 'nonpayment_day_after',
'previous_credits': current_credits
}
)
# Mark as reset
subscription.metadata['credits_reset_for_nonpayment'] = True
subscription.metadata['credits_reset_at'] = now.isoformat()
subscription.save(update_fields=['metadata'])
reset_count += 1
logger.info(f"Credits reset to 0 for unpaid subscription {subscription.id}")
except Exception as e:
logger.exception(f"Failed to reset credits for subscription {subscription.id}: {str(e)}")
logger.info(f"Day +1: Sent {reminder_count} urgent reminders, reset credits for {reset_count} subscriptions")
return {'reminders_sent': reminder_count, 'credits_reset': reset_count}
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:

View File

@@ -22,8 +22,21 @@ app.autodiscover_tasks()
# Explicitly import tasks from igny8_core/tasks directory
app.autodiscover_tasks(['igny8_core.tasks'])
# Register billing tasks after Django is ready
@app.on_after_finalize.connect
def setup_billing_tasks(sender, **kwargs):
"""Import billing tasks after Django apps are loaded."""
try:
import igny8_core.business.billing.tasks.invoice_lifecycle
import igny8_core.business.billing.tasks.subscription_renewal
except ImportError as e:
import logging
logging.getLogger(__name__).warning(f"Failed to import billing tasks: {e}")
# Celery Beat schedule for periodic tasks
# NOTE: Do NOT set static task_id in options - it causes results to overwrite instead of creating history
app.conf.beat_schedule = {
# Monthly Credits
'replenish-monthly-credits': {
'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
@@ -38,21 +51,37 @@ app.conf.beat_schedule = {
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
},
# Subscription Renewal Tasks
'send-renewal-notices': {
'task': 'billing.send_renewal_notices',
'schedule': crontab(hour=9, minute=0), # Daily at 09:00
# Stripe/PayPal: No advance notice (industry standard) - auto-pay happens on renewal day
# Bank Transfer: Invoice created 3 days before, reminders on Day 0 and Day +1
'create-bank-transfer-invoices': {
'task': 'billing.create_bank_transfer_invoices',
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 - creates invoices 3 days before renewal
},
'process-subscription-renewals': {
'task': 'billing.process_subscription_renewals',
'schedule': crontab(hour=0, minute=5), # Daily at 00:05
'schedule': crontab(hour=0, minute=5), # Daily at 00:05 - auto-pay for Stripe/PayPal
},
'send-invoice-reminders': {
'task': 'billing.send_invoice_reminders',
'schedule': crontab(hour=10, minute=0), # Daily at 10:00
'send-renewal-day-reminders': {
'task': 'billing.send_renewal_day_reminders',
'schedule': crontab(hour=10, minute=0), # Daily at 10:00 - Day 0 reminder for bank transfer
},
'check-expired-renewals': {
'task': 'billing.check_expired_renewals',
'schedule': crontab(hour=0, minute=15), # Daily at 00:15
'schedule': crontab(hour=0, minute=15), # Daily at 00:15 - expire after 7 days
},
# Send day-after reminders + reset credits for unpaid bank transfer renewals
'send-day-after-reminders': {
'task': 'billing.send_day_after_reminders',
'schedule': crontab(hour=9, minute=15), # Daily at 09:15 - Day +1 urgent + credit reset
},
# Credit Invoice Lifecycle Tasks
'send-credit-invoice-expiry-reminders': {
'task': 'billing.send_credit_invoice_expiry_reminders',
'schedule': crontab(hour=9, minute=30), # Daily at 09:30
},
'void-expired-credit-invoices': {
'task': 'billing.void_expired_credit_invoices',
'schedule': crontab(hour=0, minute=45), # Daily at 00:45
},
# Automation Tasks
'check-scheduled-automations': {
@@ -61,7 +90,7 @@ app.conf.beat_schedule = {
},
'check-test-triggers': {
'task': 'automation.check_test_triggers',
'schedule': crontab(minute='*'), # Every minute (task self-checks if any test mode enabled)
'schedule': crontab(minute='*/5'), # Every 5 minutes (task self-checks if any test mode enabled)
},
# Publishing Scheduler Tasks
'schedule-approved-content': {

View File

@@ -16,12 +16,35 @@ from igny8_core.business.billing.models import (
PaymentMethodConfig,
PlanLimitUsage,
AIModelConfig,
WebhookEvent,
)
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
def _get_invoice_type(invoice):
if invoice and getattr(invoice, 'invoice_type', None):
return invoice.invoice_type
if invoice and invoice.metadata and invoice.metadata.get('credit_package_id'):
return 'credit_package'
if invoice and getattr(invoice, 'subscription_id', None):
return 'subscription'
return 'custom'
def _get_credit_package(invoice):
if not invoice or not invoice.metadata:
return None
credit_package_id = invoice.metadata.get('credit_package_id')
if not credit_package_id:
return None
try:
return CreditPackage.objects.get(id=credit_package_id)
except CreditPackage.DoesNotExist:
return None
class CreditTransactionResource(resources.ModelResource):
"""Resource class for exporting Credit Transactions"""
class Meta:
@@ -272,87 +295,111 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
invoice.status = 'paid'
invoice.paid_at = timezone.now()
invoice.save()
# Update Subscription
if subscription and subscription.status != 'active':
invoice_type = _get_invoice_type(invoice)
# Update Subscription (subscription invoices only)
if invoice_type == 'subscription' and subscription and subscription.status != 'active':
subscription.status = 'active'
subscription.external_payment_id = obj.manual_reference
subscription.save()
# Update Account
if account.status != 'active':
# Update Account (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save()
# Add or Reset Credits (check if not already added)
from igny8_core.business.billing.models import CreditTransaction, Invoice
existing_credit = CreditTransaction.objects.filter(
account=account,
metadata__payment_id=obj.id
).exists()
if not existing_credit:
credits_to_add = 0
plan_name = ''
is_renewal = False
if subscription and subscription.plan:
credits_to_add = subscription.plan.included_credits
plan_name = subscription.plan.name
# Check if this is a renewal (previous paid invoices exist)
previous_paid = Invoice.objects.filter(
subscription=subscription,
status='paid'
).exclude(id=invoice.id if invoice else None).exists()
is_renewal = previous_paid
elif account and account.plan:
credits_to_add = account.plan.included_credits
plan_name = account.plan.name
# Check renewal by account history
is_renewal = CreditTransaction.objects.filter(
account=account,
transaction_type='subscription'
).exists()
if credits_to_add > 0:
if is_renewal:
# Renewal: Reset credits to full plan amount
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_to_add,
description=f'{plan_name} Renewal - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': obj.id,
'approved_by': request.user.email,
'is_renewal': True
}
)
self.message_user(
request,
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
level='SUCCESS'
)
else:
# Initial: Add credits
CreditService.add_credits(
if invoice_type == 'credit_package':
package = _get_credit_package(invoice)
if package:
credits_to_add = package.credits
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
CreditService.add_bonus_credits(
account=account,
amount=credits_to_add,
transaction_type='subscription',
description=f'{plan_name} - Invoice {invoice.invoice_number}',
description=f'Credit package: {package.name} ({credits_to_add} bonus credits) - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': obj.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email
}
)
self.message_user(
request,
f'Payment approved: Account activated, {credits_to_add} credits added',
request,
f'Credit package approved: {credits_to_add} bonus credits added (never expire)',
level='SUCCESS'
)
else:
if subscription and subscription.plan:
credits_to_add = subscription.plan.included_credits
plan_name = subscription.plan.name
# Check if this is a renewal (previous paid invoices exist)
previous_paid = Invoice.objects.filter(
subscription=subscription,
status='paid'
).exclude(id=invoice.id if invoice else None).exists()
is_renewal = previous_paid
elif account and account.plan:
credits_to_add = account.plan.included_credits
plan_name = account.plan.name
# Check renewal by account history
is_renewal = CreditTransaction.objects.filter(
account=account,
transaction_type='subscription'
).exists()
if credits_to_add > 0:
if is_renewal:
# Renewal: Reset credits to full plan amount
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_to_add,
description=f'{plan_name} Renewal - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': obj.id,
'approved_by': request.user.email,
'is_renewal': True
}
)
self.message_user(
request,
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
level='SUCCESS'
)
else:
# Initial: Add credits
CreditService.add_credits(
account=account,
amount=credits_to_add,
transaction_type='subscription',
description=f'{plan_name} - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': obj.id,
'approved_by': request.user.email
}
)
self.message_user(
request,
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
level='SUCCESS'
)
except Exception as e:
self.message_user(
@@ -400,15 +447,18 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
invoice.paid_at = timezone.now()
invoice.save()
# Update Subscription
if subscription:
invoice_type = _get_invoice_type(invoice)
# Update Subscription (subscription invoices only)
if invoice_type == 'subscription' and subscription:
subscription.status = 'active'
subscription.external_payment_id = payment.manual_reference
subscription.save()
# Update Account
account.status = 'active'
account.save()
# Update Account (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save()
# Add or Reset Credits based on whether this is a renewal
# Check if there are previous paid invoices for this subscription (renewal)
@@ -420,9 +470,25 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
status='paid'
).exclude(id=invoice.id).exists()
is_renewal = previous_paid_invoices
credits_added = 0
if subscription and subscription.plan and subscription.plan.included_credits > 0:
if invoice_type == 'credit_package':
package = _get_credit_package(invoice)
if package:
credits_added = package.credits
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
CreditService.add_bonus_credits(
account=account,
amount=credits_added,
description=f'Credit package: {package.name} ({credits_added} bonus credits) - Invoice {invoice.invoice_number}',
metadata={
'invoice_id': invoice.id,
'payment_id': payment.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email
}
)
elif subscription and subscription.plan and subscription.plan.included_credits > 0:
credits_added = subscription.plan.included_credits
if is_renewal:
# Renewal: Reset credits to full plan amount
@@ -1050,3 +1116,167 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
count = queryset.update(is_testing=False)
self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS)
unset_testing.short_description = 'Unset testing flag'
class WebhookEventResource(resources.ModelResource):
"""Resource class for exporting Webhook Events"""
class Meta:
model = WebhookEvent
fields = ('id', 'event_id', 'provider', 'event_type', 'processed', 'processed_at',
'error_message', 'retry_count', 'created_at')
export_order = fields
@admin.register(WebhookEvent)
class WebhookEventAdmin(ExportMixin, Igny8ModelAdmin):
"""
Payment Logs Admin - Centralized view of all payment webhook events
Shows Stripe and PayPal payment events with processing status
"""
resource_class = WebhookEventResource
list_display = [
'event_id_short',
'provider_badge',
'event_type_display',
'processed_badge',
'processing_time',
'retry_count',
'created_at',
]
list_filter = ['provider', 'event_type', 'processed', 'created_at']
search_fields = ['event_id', 'event_type', 'error_message']
readonly_fields = ['event_id', 'provider', 'event_type', 'payload_formatted', 'processed',
'processed_at', 'error_message', 'retry_count', 'created_at']
date_hierarchy = 'created_at'
ordering = ['-created_at']
fieldsets = (
('Event Info', {
'fields': ('event_id', 'provider', 'event_type', 'created_at')
}),
('Processing Status', {
'fields': ('processed', 'processed_at', 'retry_count', 'error_message'),
}),
('Payload', {
'fields': ('payload_formatted',),
'classes': ('collapse',),
}),
)
actions = ['mark_as_processed', 'retry_processing']
def event_id_short(self, obj):
"""Show truncated event ID"""
return obj.event_id[:30] + '...' if len(obj.event_id) > 30 else obj.event_id
event_id_short.short_description = 'Event ID'
def provider_badge(self, obj):
"""Show provider as a colored badge"""
colors = {
'stripe': '#635BFF', # Stripe purple
'paypal': '#003087', # PayPal blue
}
color = colors.get(obj.provider, '#666')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px; font-weight: bold;">{}</span>',
color,
obj.provider.upper()
)
provider_badge.short_description = 'Provider'
def event_type_display(self, obj):
"""Show event type with friendly formatting"""
# Map common event types to friendly names
friendly_names = {
'checkout.session.completed': 'Checkout Completed',
'payment_intent.succeeded': 'Payment Succeeded',
'payment_intent.payment_failed': 'Payment Failed',
'invoice.paid': 'Invoice Paid',
'invoice.payment_failed': 'Invoice Payment Failed',
'customer.subscription.created': 'Subscription Created',
'customer.subscription.updated': 'Subscription Updated',
'customer.subscription.deleted': 'Subscription Cancelled',
'PAYMENT.CAPTURE.COMPLETED': 'Payment Captured',
'PAYMENT.CAPTURE.DENIED': 'Payment Denied',
'PAYMENT.CAPTURE.REFUNDED': 'Payment Refunded',
'BILLING.SUBSCRIPTION.ACTIVATED': 'Subscription Activated',
'BILLING.SUBSCRIPTION.CANCELLED': 'Subscription Cancelled',
}
friendly = friendly_names.get(obj.event_type, obj.event_type)
return format_html('<span title="{}">{}</span>', obj.event_type, friendly)
event_type_display.short_description = 'Event Type'
def processed_badge(self, obj):
"""Show processing status as badge"""
if obj.processed:
return format_html(
'<span style="background-color: #10B981; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">✓ Processed</span>'
)
elif obj.error_message:
return format_html(
'<span style="background-color: #EF4444; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">✗ Failed</span>'
)
else:
return format_html(
'<span style="background-color: #F59E0B; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">⏳ Pending</span>'
)
processed_badge.short_description = 'Status'
def processing_time(self, obj):
"""Show time taken to process"""
if obj.processed and obj.processed_at:
delta = obj.processed_at - obj.created_at
ms = delta.total_seconds() * 1000
if ms < 1000:
return f'{ms:.0f}ms'
return f'{delta.total_seconds():.1f}s'
return '-'
processing_time.short_description = 'Process Time'
def payload_formatted(self, obj):
"""Show formatted JSON payload"""
import json
try:
formatted = json.dumps(obj.payload, indent=2)
return format_html(
'<pre style="max-height: 400px; overflow: auto; background: #f5f5f5; '
'padding: 10px; border-radius: 4px; font-size: 12px;">{}</pre>',
formatted
)
except:
return str(obj.payload)
payload_formatted.short_description = 'Payload'
def mark_as_processed(self, request, queryset):
"""Mark selected events as processed"""
from django.utils import timezone
count = queryset.update(processed=True, processed_at=timezone.now())
self.message_user(request, f'{count} event(s) marked as processed.', messages.SUCCESS)
mark_as_processed.short_description = 'Mark as processed'
def retry_processing(self, request, queryset):
"""Queue selected events for reprocessing"""
count = 0
for event in queryset.filter(processed=False):
# TODO: Implement actual reprocessing logic based on event type
event.retry_count += 1
event.save()
count += 1
self.message_user(
request,
f'{count} event(s) queued for reprocessing. (Manual reprocessing required)',
messages.INFO
)
retry_processing.short_description = 'Retry processing'
def has_add_permission(self, request):
"""Webhook events should only be created by webhooks"""
return False
def has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing"""
return False

View File

@@ -0,0 +1,105 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from igny8_core.business.billing.models import Invoice, Payment, CreditTransaction, CreditPackage
from igny8_core.auth.models import Account
class Command(BaseCommand):
help = "Audit invoice/payment/credits for a purchase"
def add_arguments(self, parser):
parser.add_argument("--invoice-number", dest="invoice_number", help="Invoice number (e.g., INV-26010008)")
parser.add_argument("--invoice-id", dest="invoice_id", type=int, help="Invoice ID")
parser.add_argument("--payment-id", dest="payment_id", type=int, help="Payment ID")
parser.add_argument("--account-id", dest="account_id", type=int, help="Account ID (optional)")
def handle(self, *args, **options):
invoice_number = options.get("invoice_number")
invoice_id = options.get("invoice_id")
payment_id = options.get("payment_id")
account_id = options.get("account_id")
if not any([invoice_number, invoice_id, payment_id, account_id]):
self.stderr.write("Provide at least one filter: --invoice-number, --invoice-id, --payment-id, --account-id")
return
invoice_qs = Invoice.objects.all().select_related("account", "subscription", "subscription__plan")
payment_qs = Payment.objects.all().select_related("account", "invoice")
invoice = None
if invoice_number:
invoice = invoice_qs.filter(invoice_number=invoice_number).first()
elif invoice_id:
invoice = invoice_qs.filter(id=invoice_id).first()
elif payment_id:
payment = payment_qs.filter(id=payment_id).first()
invoice = payment.invoice if payment else None
elif account_id:
invoice = invoice_qs.filter(account_id=account_id).order_by("-created_at").first()
if not invoice:
self.stderr.write("No invoice found for the provided filter.")
return
account = invoice.account
invoice_type = invoice.invoice_type
credit_package_id = (invoice.metadata or {}).get("credit_package_id")
credit_package = None
if credit_package_id:
credit_package = CreditPackage.objects.filter(id=credit_package_id).first()
self.stdout.write("=== INVOICE ===")
self.stdout.write(f"Invoice: {invoice.invoice_number} (ID={invoice.id})")
self.stdout.write(f"Type: {invoice_type}")
self.stdout.write(f"Status: {invoice.status}")
self.stdout.write(f"Total: {invoice.total} {invoice.currency}")
self.stdout.write(f"Paid at: {invoice.paid_at}")
self.stdout.write(f"Expires at: {invoice.expires_at}")
self.stdout.write(f"Void reason: {invoice.void_reason}")
self.stdout.write(f"Account: {account.id} - {account.name}")
self.stdout.write(f"Account credits: {account.credits}")
if invoice.subscription:
plan = invoice.subscription.plan
self.stdout.write(f"Subscription: {invoice.subscription.id} (status={invoice.subscription.status})")
self.stdout.write(f"Plan: {plan.id if plan else None} - {plan.name if plan else None}")
if credit_package:
self.stdout.write(f"Credit Package: {credit_package.id} - {credit_package.name} ({credit_package.credits} credits)")
payments = payment_qs.filter(invoice_id=invoice.id).order_by("created_at")
self.stdout.write("\n=== PAYMENTS ===")
if not payments.exists():
self.stdout.write("No payments found for this invoice.")
else:
for pay in payments:
self.stdout.write(
f"Payment {pay.id}: status={pay.status}, method={pay.payment_method}, amount={pay.amount} {pay.currency}, processed_at={pay.processed_at}"
)
credit_transactions = CreditTransaction.objects.filter(account=account).order_by("-created_at")[:50]
related_transactions = CreditTransaction.objects.filter(
account=account
).filter(
metadata__invoice_id=invoice.id
).order_by("created_at")
self.stdout.write("\n=== CREDIT TRANSACTIONS (RELATED) ===")
if not related_transactions.exists():
self.stdout.write("No credit transactions linked to this invoice.")
else:
for tx in related_transactions:
self.stdout.write(
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
)
if not related_transactions.exists() and invoice_type == "credit_package" and invoice.status == "paid":
self.stdout.write("\n!!! WARNING: Paid credit invoice with no linked credit transaction.")
self.stdout.write("This indicates credits were not applied.")
self.stdout.write("\n=== RECENT CREDIT TRANSACTIONS (LAST 50) ===")
for tx in credit_transactions:
self.stdout.write(
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
)
self.stdout.write("\nAudit completed at: " + timezone.now().isoformat())

View File

@@ -0,0 +1,55 @@
from datetime import timedelta
from django.db import migrations, models
from django.utils import timezone
def populate_invoice_type_and_expiry(apps, schema_editor):
Invoice = apps.get_model('billing', 'Invoice')
for invoice in Invoice.objects.all().iterator():
invoice_type = 'custom'
if getattr(invoice, 'subscription_id', None):
invoice_type = 'subscription'
else:
metadata = invoice.metadata or {}
if metadata.get('credit_package_id'):
invoice_type = 'credit_package'
invoice.invoice_type = invoice_type
if invoice_type == 'credit_package' and not invoice.expires_at:
base_time = invoice.created_at or timezone.now()
invoice.expires_at = base_time + timedelta(hours=48)
invoice.save(update_fields=['invoice_type', 'expires_at'])
def reverse_populate_invoice_type_and_expiry(apps, schema_editor):
Invoice = apps.get_model('billing', 'Invoice')
Invoice.objects.all().update(invoice_type='custom', expires_at=None)
class Migration(migrations.Migration):
dependencies = [
('billing', '0035_aimodelconfig_is_testing_and_more'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='invoice_type',
field=models.CharField(choices=[('subscription', 'Subscription'), ('credit_package', 'Credit Package'), ('addon', 'Add-on'), ('custom', 'Custom')], db_index=True, default='custom', max_length=30),
),
migrations.AddField(
model_name='invoice',
name='expires_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='invoice',
name='void_reason',
field=models.TextField(blank=True),
),
migrations.RunPython(populate_invoice_type_and_expiry, reverse_populate_invoice_type_and_expiry),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-01-20 06:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0036_invoice_type_and_expiry'),
]
operations = [
migrations.AlterField(
model_name='creditpackage',
name='features',
field=models.JSONField(blank=True, default=list, help_text='Bonus features or highlights'),
),
migrations.AlterField(
model_name='planlimitusage',
name='limit_type',
field=models.CharField(choices=[('content_ideas', 'Content Ideas'), ('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),
),
]

View File

@@ -18,7 +18,7 @@ def replenish_monthly_credits():
Runs on the first day of each month at midnight.
For each active account with a plan:
- Adds plan.included_credits to account.credits
- Resets credits to plan.included_credits
- Creates a CreditTransaction record
- Logs the replenishment
"""
@@ -52,12 +52,11 @@ def replenish_monthly_credits():
skipped += 1
continue
# Add credits using CreditService
# Reset credits using CreditService
with transaction.atomic():
new_balance = CreditService.add_credits(
new_balance = CreditService.reset_credits_for_renewal(
account=account,
amount=monthly_credits,
transaction_type='subscription',
new_amount=monthly_credits,
description=f"Monthly credit replenishment - {plan.name} plan",
metadata={
'plan_id': plan.id,
@@ -69,7 +68,7 @@ def replenish_monthly_credits():
logger.info(
f"Account {account.id} ({account.name}): "
f"Added {monthly_credits} credits (balance: {new_balance})"
f"Reset credits to {monthly_credits} (balance: {new_balance})"
)
replenished += 1

View File

@@ -32,6 +32,14 @@ class CreditBalanceViewSet(viewsets.ViewSet):
"""
ViewSet for credit balance operations
Unified API Standard v1.0 compliant
Returns:
- credits: Plan credits (reset on renewal)
- bonus_credits: Purchased credits (never expire, never reset)
- total_credits: Sum of plan + bonus credits
- plan_credits_per_month: Plan's included credits
- credits_used_this_month: Credits consumed this billing period
- credits_remaining: Total available credits
"""
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
authentication_classes = [JWTAuthentication]
@@ -52,6 +60,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
if not account:
return success_response(data={
'credits': 0,
'bonus_credits': 0,
'total_credits': 0,
'plan_credits_per_month': 0,
'credits_used_this_month': 0,
'credits_remaining': 0,
@@ -70,20 +80,25 @@ class CreditBalanceViewSet(viewsets.ViewSet):
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
# Plan credits (reset on renewal)
credits = account.credits or 0
credits_remaining = credits
# Bonus credits (never expire, from credit package purchases)
bonus_credits = account.bonus_credits or 0
# Total available
total_credits = credits + bonus_credits
credits_remaining = total_credits
data = {
'credits': credits,
'bonus_credits': bonus_credits,
'total_credits': total_credits,
'plan_credits_per_month': plan_credits_per_month,
'credits_used_this_month': credits_used_this_month,
'credits_remaining': credits_remaining,
}
# Validate and serialize data
serializer = CreditBalanceSerializer(data=data)
serializer.is_valid(raise_exception=True)
return success_response(data=serializer.validated_data, request=request)
# Validate and serialize data (skip serializer for now due to new fields)
return success_response(data=data, request=request)
@extend_schema_view(
@@ -682,7 +697,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
invoice.paid_at = timezone.now()
invoice.save()
# 3. Get and activate subscription
from igny8_core.business.billing.services.invoice_service import InvoiceService
from igny8_core.business.billing.models import CreditPackage
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
# 3. Get and activate subscription (subscription invoices only)
subscription = None
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
subscription = invoice.subscription
@@ -692,46 +712,86 @@ class AdminBillingViewSet(viewsets.ViewSet):
except Exception:
pass
if subscription:
if invoice_type == 'subscription' and subscription:
subscription.status = 'active'
subscription.external_payment_id = payment.manual_reference
subscription.save(update_fields=['status', 'external_payment_id'])
# 4. CRITICAL: Set account status to active
account.status = 'active'
account.save(update_fields=['status'])
# 5. Add credits if plan has included credits
# 4. Set account status to active (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save(update_fields=['status'])
# 5. Add/Reset credits based on invoice type
credits_added = 0
try:
plan = None
if subscription and subscription.plan:
plan = subscription.plan
elif account and account.plan:
plan = account.plan
if plan and plan.included_credits > 0:
credits_added = plan.included_credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='subscription',
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id if invoice else None,
'payment_id': payment.id,
'plan_id': plan.id,
'approved_by': request.user.email
}
)
if invoice_type == 'credit_package':
credit_package_id = invoice.metadata.get('credit_package_id') if invoice and invoice.metadata else None
if credit_package_id:
package = CreditPackage.objects.get(id=credit_package_id)
credits_added = package.credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='purchase',
description=f'Credit package: {package.name} ({credits_added} credits) - Invoice {invoice.invoice_number if invoice else "N/A"}',
metadata={
'invoice_id': invoice.id if invoice else None,
'payment_id': payment.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email
}
)
elif invoice_type == 'subscription':
plan = None
if subscription and subscription.plan:
plan = subscription.plan
elif account and account.plan:
plan = account.plan
if plan and plan.included_credits > 0:
credits_added = plan.included_credits
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_added,
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id if invoice else None,
'payment_id': payment.id,
'plan_id': plan.id,
'approved_by': request.user.email
}
)
except Exception as credit_error:
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
# Don't fail the approval if credits fail - account is still activated
# Don't fail the approval if credits fail
logger.info(
f'Payment approved: Payment {payment.id}, Account {account.id} set to active, '
f'{credits_added} credits added'
f'Payment approved: Payment {payment.id}, Account {account.id}, '
f'invoice_type={invoice_type}, credits_added={credits_added}'
)
# Log to WebhookEvent for unified payment logs
from igny8_core.business.billing.models import WebhookEvent
WebhookEvent.record_event(
event_id=f'{payment.payment_method}-approved-{payment.id}-{timezone.now().timestamp()}',
provider=payment.payment_method,
event_type='payment.approved',
payload={
'payment_id': payment.id,
'invoice_id': invoice.id if invoice else None,
'invoice_number': invoice.invoice_number if invoice else None,
'invoice_type': invoice_type,
'account_id': account.id,
'amount': str(payment.amount),
'currency': payment.currency,
'manual_reference': payment.manual_reference,
'approved_by': request.user.email,
'credits_added': credits_added,
'subscription_id': subscription.id if subscription else None,
},
processed=True
)
# 6. Send approval email
@@ -783,6 +843,24 @@ class AdminBillingViewSet(viewsets.ViewSet):
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {rejection_reason}')
# Log to WebhookEvent for unified payment logs
from igny8_core.business.billing.models import WebhookEvent
WebhookEvent.record_event(
event_id=f'{payment.payment_method}-rejected-{payment.id}-{timezone.now().timestamp()}',
provider=payment.payment_method,
event_type='payment.rejected',
payload={
'payment_id': payment.id,
'account_id': account.id if account else None,
'amount': str(payment.amount),
'currency': payment.currency,
'manual_reference': payment.manual_reference,
'rejected_by': request.user.email,
'rejection_reason': rejection_reason,
},
processed=True
)
# Send rejection email
try:
from igny8_core.business.billing.services.email_service import BillingEmailService

View File

@@ -550,7 +550,9 @@ CELERY_REDIS_BACKEND_USE_SSL = os.getenv('REDIS_SSL_ENABLED', 'false').lower() =
# Publish/Sync Logging Configuration
PUBLISH_SYNC_LOG_DIR = os.path.join(BASE_DIR, 'logs', 'publish-sync-logs')
BILLING_LOG_DIR = os.path.join(BASE_DIR, 'logs', 'billing-logs')
os.makedirs(PUBLISH_SYNC_LOG_DIR, exist_ok=True)
os.makedirs(BILLING_LOG_DIR, exist_ok=True)
LOGGING = {
'version': 1,
@@ -566,6 +568,11 @@ LOGGING = {
'style': '{',
'datefmt': '%Y-%m-%d %H:%M:%S',
},
'billing': {
'format': '[{asctime}] [{levelname}] [{name}] {message}',
'style': '{',
'datefmt': '%Y-%m-%d %H:%M:%S',
},
},
'handlers': {
'console': {
@@ -593,6 +600,20 @@ LOGGING = {
'backupCount': 10,
'formatter': 'publish_sync',
},
'billing_file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(BILLING_LOG_DIR, 'billing.log'),
'maxBytes': 10 * 1024 * 1024, # 10 MB
'backupCount': 20,
'formatter': 'billing',
},
'payment_file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(BILLING_LOG_DIR, 'payments.log'),
'maxBytes': 10 * 1024 * 1024, # 10 MB
'backupCount': 20,
'formatter': 'billing',
},
},
'loggers': {
'publish_sync': {
@@ -610,6 +631,16 @@ LOGGING = {
'level': 'INFO',
'propagate': False,
},
'billing': {
'handlers': ['console', 'billing_file'],
'level': 'INFO',
'propagate': False,
},
'payments': {
'handlers': ['console', 'payment_file'],
'level': 'INFO',
'propagate': False,
},
'auth.middleware': {
'handlers': ['console'],
'level': 'INFO',
@@ -626,6 +657,7 @@ LOGGING = {
# Celery Results Backend
CELERY_RESULT_BACKEND = 'django-db'
CELERY_CACHE_BACKEND = 'django-cache'
CELERY_RESULT_EXTENDED = True # Store task name, args, kwargs in results
# Import/Export Settings
IMPORT_EXPORT_USE_TRANSACTIONS = True
@@ -704,6 +736,7 @@ UNFOLD = {
{"title": "Credit Packages", "icon": "card_giftcard", "link": lambda request: "/admin/billing/creditpackage/"},
{"title": "Payment Methods (Global)", "icon": "credit_card", "link": lambda request: "/admin/billing/paymentmethodconfig/"},
{"title": "Account Payment Methods", "icon": "account_balance_wallet", "link": lambda request: "/admin/billing/accountpaymentmethod/"},
{"title": "Payment Logs", "icon": "receipt", "link": lambda request: "/admin/billing/webhookevent/"},
],
},
# Credits & AI Usage (CONSOLIDATED)

View File

@@ -0,0 +1,12 @@
{% extends "emails/base.html" %}
{% block content %}
<h2>Invoice Cancelled</h2>
<p>Hi {{ account_name }},</p>
<p>Your credit invoice <strong>#{{ invoice_number }}</strong> was cancelled.</p>
<p>
You can create a new credit purchase anytime from your billing page:
<a href="{{ invoice_url }}">Billing</a>
</p>
<p>If you have any questions, contact support at {{ support_email }}.</p>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "emails/base.html" %}
{% block content %}
<h2>Invoice Expired</h2>
<p>Hi {{ account_name }},</p>
<p>Your credit invoice <strong>#{{ invoice_number }}</strong> has expired and was voided.</p>
<p>
You can create a new credit purchase anytime from your billing page:
<a href="{{ invoice_url }}">Billing</a>
</p>
<p>If you have any questions, contact support at {{ support_email }}.</p>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "emails/base.html" %}
{% block content %}
<h2>Invoice Expiring Soon</h2>
<p>Hi {{ account_name }},</p>
<p>Your credit invoice <strong>#{{ invoice_number }}</strong> will expire soon.</p>
<ul>
<li><strong>Amount:</strong> {{ currency }} {{ total_amount }}</li>
<li><strong>Expires at:</strong> {{ expires_at }}</li>
</ul>
<p>
Please complete payment before it expires:
<a href="{{ invoice_url }}">View Invoice</a>
</p>
<p>If you have any questions, contact support at {{ support_email }}.</p>
{% endblock %}

View File

@@ -0,0 +1,754 @@
# IGNY8 Billing & Payments - Complete Reference
> **Last Updated:** January 20, 2026
> **Version:** 2.0 (Two-Pool Credit System)
---
## Table of Contents
1. [System Overview](#1-system-overview)
2. [Credit System](#2-credit-system)
3. [Payment Methods](#3-payment-methods)
4. [Subscription Lifecycle](#4-subscription-lifecycle)
5. [Credit Packages](#5-credit-packages)
6. [Invoice System](#6-invoice-system)
7. [Renewal Workflow](#7-renewal-workflow)
8. [Admin Operations](#8-admin-operations)
9. [API Reference](#9-api-reference)
10. [Database Schema](#10-database-schema)
---
## 1. System Overview
### Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ IGNY8 BILLING SYSTEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ STRIPE │ │ PAYPAL │ │ BANK TRANSFER│ │
│ │ (Card/Intl) │ │ (Intl) │ │ (PK Only) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PAYMENT GATEWAY LAYER │ │
│ │ • Webhook Processing • Payment Verification • Logging │ │
│ └─────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ INVOICE SERVICE │ │
│ │ • Create Invoice • Update Status • PDF Generation │ │
│ └─────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CREDIT SERVICE │ │
│ │ • Plan Credits • Bonus Credits • Deduction • Reset │ │
│ └─────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ACCOUNT │ │
│ │ credits (plan) │ bonus_credits (purchased) │ status │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Key Files
| Component | File Path |
|-----------|-----------|
| Credit Service | `business/billing/services/credit_service.py` |
| Invoice Service | `business/billing/services/invoice_service.py` |
| Payment Service | `business/billing/services/payment_service.py` |
| Email Service | `business/billing/services/email_service.py` |
| Stripe Webhooks | `business/billing/views/stripe_views.py` |
| PayPal Webhooks | `business/billing/views/paypal_views.py` |
| Subscription Renewal | `business/billing/tasks/subscription_renewal.py` |
| Invoice Lifecycle | `business/billing/tasks/invoice_lifecycle.py` |
| Billing Admin | `modules/billing/admin.py` |
| Billing Models | `business/billing/models.py` |
---
## 2. Credit System
### Two-Pool Credit Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ ACCOUNT CREDITS │
├────────────────────────────┬────────────────────────────────┤
│ PLAN CREDITS │ BONUS CREDITS │
│ (account.credits) │ (account.bonus_credits) │
├────────────────────────────┼────────────────────────────────┤
│ • From subscription plan │ • From credit packages │
│ • Resets on renewal │ • NEVER expire │
│ • Used FIRST │ • Used SECOND (after plan=0) │
│ • Max = plan.included_ │ • No maximum limit │
│ credits │ │
└────────────────────────────┴────────────────────────────────┘
```
### Credit Deduction Flow
```
┌──────────────────┐
│ CREDIT REQUEST │
│ (e.g., 50) │
└────────┬─────────┘
┌──────────────────┐
│ Check Total │
│ credits + bonus │
│ >= requested? │
└────────┬─────────┘
┌──────────────┴──────────────┐
│ NO │ YES
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ INSUFFICIENT │ │ Plan Credits >= 50?│
│ Return False │ └─────────┬──────────┘
└────────────────┘ │
┌──────────┴──────────┐
│ YES │ NO
▼ ▼
┌───────────────┐ ┌────────────────────┐
│ Deduct from │ │ Deduct plan credits│
│ plan credits │ │ to 0, remainder │
│ only │ │ from bonus_credits │
└───────────────┘ └────────────────────┘
```
### Credit Operations
| Operation | Method | Affects | Description |
|-----------|--------|---------|-------------|
| Add Plan Credits | `add_credits()` | `credits` | Initial subscription, manual adjustment |
| Add Bonus Credits | `add_bonus_credits()` | `bonus_credits` | Credit package purchases |
| Deduct Credits | `deduct_credits()` | Both pools | AI operations, uses plan first |
| Reset on Renewal | `reset_credits_for_renewal()` | `credits` only | Sets plan credits to new amount |
| Check Balance | `check_credits()` | Read-only | Returns total available |
### Credit Transaction Types
| Type | Description |
|------|-------------|
| `subscription` | Plan credits from subscription |
| `purchase` | Bonus credits from credit package |
| `usage` | Credit consumption for AI operations |
| `refund` | Credits returned due to failed operation |
| `manual` | Admin adjustment |
| `renewal` | Reset during subscription renewal |
| `bonus` | Promotional bonus credits |
---
## 3. Payment Methods
### Payment Method by Country
```
┌─────────────────────────────────────────────────────────────┐
│ PAYMENT METHOD MATRIX │
├─────────────────┬───────────────────────────────────────────┤
│ COUNTRY │ AVAILABLE METHODS │
├─────────────────┼───────────────────────────────────────────┤
│ Pakistan (PK) │ ✅ Bank Transfer ✅ Stripe (Card) │
│ │ ❌ PayPal (not available) │
├─────────────────┼───────────────────────────────────────────┤
│ Other (Intl) │ ✅ Stripe (Card) ✅ PayPal │
│ │ ❌ Bank Transfer (not available) │
└─────────────────┴───────────────────────────────────────────┘
```
### Payment Flow by Method
#### Stripe (Card) Flow
```
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ User │────▶│ Frontend │────▶│ Create │────▶│ Stripe │
│ Clicks │ │ Checkout │ │ Checkout │ │ Hosted │
│ Pay │ │ │ │ Session │ │ Page │
└─────────┘ └──────────┘ └─────────────┘ └────┬─────┘
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ Account │◀────│ Credit │◀────│ Invoice │◀────│ Webhook │
│ Active │ │ Added │ │ Paid │ │ Received │
└─────────┘ └──────────┘ └─────────────┘ └──────────┘
```
#### PayPal Flow
```
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ User │────▶│ Frontend │────▶│ Create │────▶│ PayPal │
│ Clicks │ │ Checkout │ │ Order/Sub │ │ Hosted │
│ Pay │ │ │ │ │ │ Page │
└─────────┘ └──────────┘ └─────────────┘ └────┬─────┘
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ Account │◀────│ Credit │◀────│ Invoice │◀────│ Webhook │
│ Active │ │ Added │ │ Paid │ │ CAPTURE │
└─────────┘ └──────────┘ └─────────────┘ └──────────┘
```
#### Bank Transfer Flow (PK Only)
```
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ User │────▶│ View │────▶│ Manual │────▶│ Upload │
│ Selects │ │ Bank │ │ Transfer │ │ Proof │
│ Bank │ │ Details │ │ to Bank │ │ │
└─────────┘ └──────────┘ └─────────────┘ └────┬─────┘
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ Account │◀────│ Credit │◀────│ Admin │◀────│ Payment │
│ Active │ │ Added │ │ Approves │ │ Created │
└─────────┘ └──────────┘ └─────────────┘ └──────────┘
```
---
## 4. Subscription Lifecycle
### Subscription States
```
┌─────────────────────────────────────────────────────────────────────────┐
│ SUBSCRIPTION STATE MACHINE │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────┐
│ NEW │
│ SIGNUP │
└────┬────┘
│ Create subscription + invoice
┌─────────────┐ Payment Failed ┌─────────────┐
│ PENDING │─────────────────────────▶│ FAILED │
│ (awaiting │ │ │
│ payment) │ └─────────────┘
└──────┬──────┘
│ Payment Success
┌─────────────┐
│ ACTIVE │◀─────────────────────────────────────┐
│ │ Renewal Payment Success │
└──────┬──────┘ │
│ Renewal Date │
▼ │
┌─────────────┐ Payment Within ┌───────────┴─┐
│ PENDING │ Grace Period │ │
│ RENEWAL │────────────────────────▶│ ACTIVE │
│ │ │ (renewed) │
└──────┬──────┘ └─────────────┘
│ Grace Period Expired (7 days)
┌─────────────┐
│ EXPIRED │ Manual Reactivation
│ │────────────────────────▶ Back to PENDING
└─────────────┘
┌─────────────┐
│ CANCELLED │ User-initiated cancellation
│ │ (end of current period)
└─────────────┘
```
### Subscription Status Reference
| Status | Credits Access | Can Use Features | Next Action |
|--------|----------------|------------------|-------------|
| `pending` | ❌ No | ❌ No | Complete payment |
| `active` | ✅ Yes | ✅ Yes | None (auto-renews) |
| `pending_renewal` | ✅ Yes (24h) | ✅ Yes | Pay invoice |
| `expired` | ❌ No | ❌ Limited | Resubscribe |
| `cancelled` | ✅ Until end | ✅ Until end | None |
| `failed` | ❌ No | ❌ No | Retry payment |
---
## 5. Credit Packages
### Available Packages
| Package | Credits | USD Price | PKR Price | Per Credit |
|---------|---------|-----------|-----------|------------|
| Starter | 500 | $50.00 | ≈ PKR 14,000 | $0.10 |
| Growth | 2,000 | $200.00 | ≈ PKR 56,000 | $0.10 |
| Scale | 5,000 | $300.00 | ≈ PKR 83,000 | $0.06 |
| Enterprise | 20,000 | $1,200.00 | ≈ PKR 334,000 | $0.06 |
### Credit Package Purchase Flow
```
┌───────────────────────────────────────────────────────────────────────────┐
│ CREDIT PACKAGE PURCHASE FLOW │
└───────────────────────────────────────────────────────────────────────────┘
User selects package
┌───────────────────┐
│ Create Invoice │
│ type='credit_ │
│ package' │
└─────────┬─────────┘
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ Stripe/PayPal? │────▶│ Auto-process via │────▶│ Webhook confirms │
│ │ │ payment gateway │ │ payment success │
└─────────┬─────────┘ └───────────────────┘ └─────────┬─────────┘
│ │
│ Bank Transfer? │
▼ │
┌───────────────────┐ ┌───────────────────┐ │
│ Payment created │────▶│ Admin reviews & │ │
│ status='pending_ │ │ approves payment │ │
│ approval' │ └─────────┬─────────┘ │
└───────────────────┘ │ │
▼ ▼
┌───────────────────────────────────────┐
│ PAYMENT APPROVED │
└─────────────────┬─────────────────────┘
┌───────────────────────────────────────┐
│ CreditService.add_bonus_credits() │
│ • Adds to account.bonus_credits │
│ • Creates CreditTransaction │
│ • NEVER expires │
└───────────────────────────────────────┘
```
### Credit Package Invoice Lifecycle
```
Invoice Created ──▶ 48 hours ──▶ Reminder Sent ──▶ 48 hours ──▶ Invoice Voided
│ │ │
│ │ │
▼ ▼ ▼
status='pending' status='pending' status='void'
(reminder sent) (auto-cancelled)
```
---
## 6. Invoice System
### Invoice Types
| Type | Description | Auto-Pay | Manual Pay |
|------|-------------|----------|------------|
| `subscription` | Monthly plan payment | Stripe/PayPal | Bank Transfer |
| `credit_package` | One-time credit purchase | Stripe/PayPal | Bank Transfer |
| `addon` | Additional features | Stripe/PayPal | Bank Transfer |
| `custom` | Manual/admin-created | ❌ | ✅ |
### Invoice Status Flow
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ DRAFT │────▶│ SENT │────▶│ PENDING │────▶│ PAID │
└─────────┘ └─────────┘ └────┬────┘ └─────────┘
┌────────┴────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ OVERDUE │ │ FAILED │
└────┬─────┘ └──────────┘
┌──────────┐ ┌──────────┐
│ VOID │ │CANCELLED │
└──────────┘ └──────────┘
```
### Invoice Status Reference
| Status | Description | User Action |
|--------|-------------|-------------|
| `draft` | Being created | - |
| `sent` | Delivered to user | Pay Now |
| `pending` | Awaiting payment | Pay Now |
| `overdue` | Past due date | Pay Now (urgent) |
| `paid` | Payment received | Download PDF |
| `failed` | Payment failed | Retry/Pay Now |
| `void` | Cancelled by system | - |
| `cancelled` | Cancelled by admin | - |
---
## 7. Renewal Workflow
### Renewal Timeline by Payment Method
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ RENEWAL TIMELINE - STRIPE/PAYPAL (AUTO-PAY) │
│ Industry Standard: No Advance Notice │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Day 0 (Renewal) Day +1 Day +7 │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ │
│ │Auto-Pay │ │ If Failed: │ │ Expired │ │
│ │ Attempt │ │ Retry + │ │ (after │ │
│ └────┬─────┘ │ Email Sent │ │ retries) │ │
│ │ └────────────┘ └──────────┘ │
│ ┌────┴────┐ │
│ │ SUCCESS │──▶ Receipt email + Credits reset to plan amount │
│ └────┬────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ FAILURE │──▶ Payment failed email, Stripe retries 4x over 7 days │
│ └─────────┘ │
│ │
│ EMAIL SCHEDULE (Industry Standard - Netflix, Spotify, Adobe): │
│ • Payment Success: Receipt immediately │
│ • Payment Failed: Notification after each retry attempt │
│ • Final Warning: 1 day before account suspension │
│ • Account Suspended: When grace period ends │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ RENEWAL TIMELINE - BANK TRANSFER │
│ Simplified 3-Email Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Day -3 Day 0 Day +1 Day +7 │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Invoice │ │Reminder │ │ Urgent │ │ Expired │ │
│ │Created │ │ Email │ │Reminder │ │ │ │
│ │+Email │ │(if not │ │+Credits │ │ │ │
│ │ │ │ paid) │ │ Reset │ │ │ │
│ └────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Timeline: │
│ • Day -3: Invoice created + Email sent with payment instructions │
│ • Day 0: Reminder email (renewal day, if still unpaid) │
│ • Day +1: Urgent reminder + credits reset to 0 (if unpaid) │
│ • Day +7: Subscription expired │
│ │
│ EMAILS: │
│ 1. Invoice Email (Day -3): Invoice attached, bank details, Pay Now link │
│ 2. Renewal Reminder (Day 0): "Your subscription renews today" │
│ 3. Urgent Reminder (Day +1): "Payment overdue - action required" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Renewal Credit Behavior
```
┌───────────────────────────────────────────────────────────────────┐
│ CREDIT RESET ON RENEWAL │
├───────────────────────────────────────────────────────────────────┤
│ │
│ SCENARIO 1: Payment Before Renewal │
│ ───────────────────────────────────── │
│ • Credits NOT reset early │
│ • On payment confirmation: │
│ - plan credits → reset to plan.included_credits │
│ - bonus_credits → UNCHANGED (never affected) │
│ │
│ SCENARIO 2: Payment After Renewal (within 24h) │
│ ────────────────────────────────────────────── │
│ • Credits stay unchanged for 24 hours │
│ • On payment confirmation: │
│ - plan credits → reset to plan.included_credits │
│ - bonus_credits → UNCHANGED │
│ │
│ SCENARIO 3: No Payment After 24 Hours │
│ ───────────────────────────────────── │
│ • plan credits → reset to 0 (task: reset_unpaid_renewal_credits) │
│ • bonus_credits → UNCHANGED (user can still use these) │
│ • Warning email sent │
│ │
│ SCENARIO 4: Payment After Credit Reset │
│ ────────────────────────────────────── │
│ • On payment confirmation: │
│ - plan credits → reset to plan.included_credits (restored) │
│ - bonus_credits → UNCHANGED │
│ │
└───────────────────────────────────────────────────────────────────┘
```
### Celery Tasks Schedule
| Task | Schedule | Purpose |
|------|----------|---------|
| `create_bank_transfer_invoices` | Daily 09:00 | Create invoices 3 days before renewal (bank transfer only) |
| `process_subscription_renewals` | Daily 00:05 | Process auto-pay renewals (Stripe/PayPal) |
| `send_renewal_day_reminders` | Daily 10:00 | Send Day 0 reminder for bank transfer (if unpaid) |
| `send_day_after_reminders` | Daily 09:15 | Send Day +1 urgent reminders + reset credits |
| `check_expired_renewals` | Daily 00:15 | Mark subscriptions expired after 7-day grace period |
| `send_credit_invoice_expiry_reminders` | Daily 09:30 | Remind about expiring credit package invoices |
| `void_expired_credit_invoices` | Daily 00:45 | Auto-void credit invoices after 48h |
### Email Schedule Summary
**Stripe/PayPal (Auto-Pay) - Industry Standard:**
| Event | Email |
|-------|-------|
| Payment Success | ✅ Receipt/Confirmation |
| Payment Failed | ⚠️ Retry notification (per attempt) |
| Final Warning | 🚨 1 day before suspension |
| Account Suspended | ❌ Subscription expired |
**Bank Transfer (Manual Pay):**
| Day | Email |
|-----|-------|
| Day -3 | 📧 Invoice created + payment instructions |
| Day 0 | ⏰ Renewal day reminder (if unpaid) |
| Day +1 | 🚨 Urgent reminder + credits reset warning |
---
## 8. Admin Operations
### Webhook Events (Payment Logs)
**Location:** Admin → Billing → Webhook Events
| Column | Description |
|--------|-------------|
| Event ID | Unique ID from Stripe/PayPal |
| Provider | STRIPE or PAYPAL badge |
| Event Type | e.g., `checkout.session.completed`, `PAYMENT.CAPTURE.COMPLETED` |
| Status | Processed ✓, Failed ✗, Pending ⏳ |
| Process Time | Actual processing duration |
| Created | When webhook received |
**Actions:**
- Mark as processed
- Retry processing
- View full payload (JSON)
### Payment Approval (Bank Transfer)
**Location:** Admin → Billing → Payments
**Approval Flow:**
1. Filter by `status = pending_approval`
2. Review `manual_reference` and `manual_notes`
3. Check proof of payment upload
4. Change status to `succeeded`
5. System automatically:
- Updates invoice to `paid`
- Activates account (if subscription)
- Adds credits (plan or bonus based on invoice type)
### Manual Credit Adjustment
**Location:** Admin → Billing → Credit Transactions
**To add credits manually:**
1. Go to Account admin
2. Edit the account
3. Modify `credits` (plan) or `bonus_credits` (purchased)
4. Save with note in admin_notes
**OR use shell:**
```python
from igny8_core.business.billing.services.credit_service import CreditService
# Add plan credits
CreditService.add_credits(
account=account,
amount=500,
transaction_type='manual',
description='Manual adjustment - support ticket #123'
)
# Add bonus credits
CreditService.add_bonus_credits(
account=account,
amount=500,
description='Promotional bonus - January 2026'
)
```
---
## 9. API Reference
### Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/billing/credits/` | GET | Get credit balance |
| `/api/v1/billing/credits/usage/` | GET | Get usage statistics |
| `/api/v1/billing/invoices/` | GET | List invoices |
| `/api/v1/billing/invoices/{id}/` | GET | Invoice detail |
| `/api/v1/billing/invoices/{id}/pdf/` | GET | Download invoice PDF |
| `/api/v1/billing/payments/` | GET | List payments |
| `/api/v1/billing/plans/` | GET | List available plans |
| `/api/v1/billing/subscriptions/` | GET | List subscriptions |
| `/api/v1/billing/credit-packages/` | GET | List credit packages |
| `/api/v1/billing/purchase/credits/` | POST | Purchase credit package |
| `/api/v1/billing/subscribe/` | POST | Subscribe to plan |
| `/api/v1/webhooks/stripe/` | POST | Stripe webhook endpoint |
| `/api/v1/webhooks/paypal/` | POST | PayPal webhook endpoint |
### Credit Balance Response
```json
{
"credits": 3500,
"bonus_credits": 2000,
"total_credits": 5500,
"credits_used_this_month": 1500,
"plan_credits_per_month": 5000,
"subscription_plan": "Scale",
"period_end": "2026-02-12T00:00:00Z"
}
```
---
## 10. Database Schema
### Core Tables
```
┌─────────────────────────────────────────────────────────────────────────┐
│ accounts │
├─────────────────────────────────────────────────────────────────────────┤
│ id │ PK │
│ name │ Account name │
│ status │ pending, active, expired, etc. │
│ credits │ Plan credits (resets on renewal) │
│ bonus_credits │ Purchased credits (never expire) │
│ plan_id │ FK → plans │
│ billing_email │ Email for invoices │
│ billing_country │ Country code (PK, US, etc.) │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ subscriptions │
├─────────────────────────────────────────────────────────────────────────┤
│ id │ PK │
│ account_id │ FK → accounts │
│ plan_id │ FK → plans │
│ status │ pending, active, pending_renewal, expired, etc. │
│ current_period_start │ Start of current billing period │
│ current_period_end │ End of current billing period (renewal date) │
│ metadata │ JSON (stripe_subscription_id, paypal_sub_id) │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ invoices │
├─────────────────────────────────────────────────────────────────────────┤
│ id │ PK │
│ invoice_number │ Unique invoice number (INV-2026-00001) │
│ account_id │ FK → accounts │
│ subscription_id │ FK → subscriptions (nullable) │
│ invoice_type │ subscription, credit_package, addon, custom │
│ status │ draft, sent, pending, paid, overdue, void, etc. │
│ total_amount │ Total amount │
│ currency │ USD, PKR │
│ due_date │ Payment due date │
│ paid_at │ When payment received │
│ metadata │ JSON (credit_package_id, etc.) │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ payments │
├─────────────────────────────────────────────────────────────────────────┤
│ id │ PK │
│ invoice_id │ FK → invoices │
│ account_id │ FK → accounts │
│ amount │ Payment amount │
│ currency │ USD, PKR │
│ payment_method │ stripe, paypal, bank_transfer │
│ status │ pending_approval, processing, succeeded, failed│
│ stripe_payment_intent_id│ Stripe reference │
│ paypal_order_id │ PayPal reference │
│ manual_reference │ Bank transfer reference │
│ approved_by_id │ FK → users (admin who approved) │
│ approved_at │ When approved │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ credit_transactions │
├─────────────────────────────────────────────────────────────────────────┤
│ id │ PK │
│ account_id │ FK → accounts │
│ transaction_type │ subscription, purchase, usage, refund, manual, etc. │
│ amount │ Credits added (+) or deducted (-) │
│ balance_after │ Balance after this transaction │
│ description │ Human-readable description │
│ metadata │ JSON (invoice_id, payment_id, etc.) │
│ created_at │ Timestamp │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ webhook_events │
├─────────────────────────────────────────────────────────────────────────┤
│ id │ PK │
│ event_id │ Unique event ID from provider │
│ provider │ stripe, paypal │
│ event_type │ checkout.session.completed, PAYMENT.CAPTURE.COMPLETED │
│ payload │ JSON - full webhook payload │
│ processed │ Boolean - successfully processed? │
│ processed_at │ When processed │
│ error_message│ Error if processing failed │
│ retry_count │ Number of retry attempts │
│ created_at │ When received │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Quick Reference Card
### Credit Consumption Priority
1. **Plan credits** used first
2. **Bonus credits** used only when plan credits = 0
### What Happens on Renewal
| Event | Plan Credits | Bonus Credits |
|-------|--------------|---------------|
| Payment success | Reset to plan amount | No change |
| No payment (24h) | Reset to 0 | No change |
| Late payment | Reset to plan amount | No change |
### Payment Method Availability
| Country | Stripe | PayPal | Bank Transfer |
|---------|--------|--------|---------------|
| Pakistan (PK) | ✅ | ❌ | ✅ |
| Others | ✅ | ✅ | ❌ |
### Invoice Expiry
| Invoice Type | Expiry |
|--------------|--------|
| Subscription | 7 days (grace period) |
| Credit Package | 48 hours |
---
*Document generated from current codebase implementation as of January 20, 2026*

View File

@@ -0,0 +1,196 @@
# IGNY8 Billing System Master Document
**Last Updated:** 2026-01-20
This document is the authoritative reference for the billing system implementation, including models, invoice lifecycle, payment flows, credit reset logic, and notification timing.
---
## 1) Core Principles
- **No hardcoded products**: Plans, credit packages, and future add-ons are data-driven.
- **Explicit invoice type**: `subscription`, `credit_package`, `addon`, `custom`.
- **Correct crediting**:
- Subscription credits reset to **0** at cycle end.
- Subscription credits reset to **full plan amount** on renewal/activation.
- Credit package credits add to balance.
- **Lifecycle governance**: Credit invoices expire automatically and can be cancelled by users.
- **Auditability**: Every credit change is recorded in `CreditTransaction`.
---
## 2) Data Model Overview
### 2.1 Invoice
| Field | Purpose |
|---|---|
| `invoice_type` | `subscription`, `credit_package`, `addon`, `custom` |
| `status` | `draft`, `pending`, `paid`, `void`, `uncollectible` |
| `expires_at` | Expiry for credit invoices |
| `void_reason` | Cancellation/expiration reason |
| `line_items` | Itemized charges (productdriven) |
| `metadata` | Compatibility + gateway context |
### 2.2 Payment
| Field | Purpose |
|---|---|
| `status` | `pending_approval`, `succeeded`, `failed`, `refunded` |
| `payment_method` | `stripe`, `paypal`, `bank_transfer`, `local_wallet`, `manual` |
| `invoice` | FK to invoice |
| `metadata` | Gateway and fulfillment details |
### 2.3 Credits
| Field | Purpose |
|---|---|
| `Account.credits` | Current balance |
| `CreditTransaction` | Immutable ledger of changes |
---
## 3) Invoice Types and Fulfillment Rules
| Invoice Type | Fulfillment | Account Status Change |
|---|---|---|
| `subscription` | Reset credits to plan amount | Activate account + subscription |
| `credit_package` | Add package credits | No status change |
| `addon` | Provision add-on entitlement | No status change |
| `custom` | No credits unless specified | No status change |
---
## 4) Flowcharts
### 4.1 Subscription Purchase (Stripe / PayPal / Bank Transfer)
```mermaid
flowchart TD
A[User selects plan] --> B[Create subscription invoice]
B --> C{Payment Method}
C -->|Stripe/PayPal| D[Gateway checkout]
D --> E[Webhook payment succeeded]
E --> F[Invoice paid]
F --> G[Reset credits to full plan amount]
G --> H[Account + subscription active]
C -->|Bank Transfer| I[User submits confirmation]
I --> J[Payment pending_approval]
J --> K[Admin approves]
K --> F
```
### 4.2 Subscription Renewal (Automatic + Manual)
```mermaid
flowchart TD
A[Billing cycle end] --> B[Reset credits to 0]
B --> C[Create renewal invoice]
C --> D{Auto method available?}
D -->|Yes| E[Gateway charges]
E --> F[Webhook payment succeeded]
F --> G[Reset credits to full plan amount]
G --> H[Subscription active]
D -->|No| I[Manual payment required]
I --> J[Invoice emailed]
J --> K[Admin approves]
K --> G
```
### 4.3 Credit Package Purchase (All Methods)
```mermaid
flowchart TD
A[User selects credit package] --> B[Create credit invoice + expires_at]
B --> C{Payment Method}
C -->|Stripe/PayPal| D[Gateway checkout]
D --> E[Webhook payment succeeded]
E --> F[Add package credits]
C -->|Bank Transfer| G[User submits confirmation]
G --> H[Payment pending_approval]
H --> I[Admin approves]
I --> F
```
### 4.4 Credit Invoice Expiry
```mermaid
flowchart TD
A[Scheduler checks pending credit invoices] --> B{expires_at <= now?}
B -->|Yes| C[Void invoice + notify user]
B -->|No| D[No action]
```
### 4.5 Credit Invoice Cancellation (User)
```mermaid
flowchart TD
A[User clicks cancel] --> B{Pending credit invoice?}
B -->|Yes| C[Set status=void, void_reason=user_cancelled]
C --> D[Notify user]
B -->|No| E[Reject request]
```
---
## 5) Notification Matrix (Required Emails)
| Event | Email | Timing |
|---|---|---|
| Manual payment submitted | Payment confirmation | Immediate |
| Manual payment approved | Payment approved | Immediate |
| Manual payment rejected | Payment rejected | Immediate |
| Renewal notice | Subscription renewal notice | N days before end |
| Renewal invoice | Invoice email | At renewal creation |
| Renewal reminder | Invoice reminder | Every 3 days until paid |
| Credit invoice expiring | Credit invoice expiring | 24h before expires_at |
| Credit invoice expired | Credit invoice expired | At void |
| Credit invoice cancelled | Credit invoice cancelled | Immediate |
| Payment failed | Payment failed | Immediate |
| Low credits | Low credits warning | Threshold crossing |
| Subscription expired | Subscription expired | When grace period ends |
---
## 6) Scheduled Tasks
| Task | Purpose | Schedule |
|---|---|---|
| send_renewal_notices | Renewal reminders | Daily 09:00 |
| process_subscription_renewals | Create renewal invoices | Daily 00:05 |
| send_invoice_reminders | Remind pending renewals | Daily 10:00 |
| check_expired_renewals | Expire grace period | Daily 00:15 |
| send_credit_invoice_expiry_reminders | Credit invoice expiry reminder | Daily 09:30 |
| void_expired_credit_invoices | Auto-void expired credit invoices | Daily 00:45 |
| replenish_monthly_credits | Monthly credit reset (legacy) | 1st day monthly |
---
## 7) Key Implementation Rules
1. **Invoice type drives fulfillment** (no plan/package hardcoding).
2. **Subscription credits**: reset to **0** at cycle end, then set to **full amount** on renewal.
3. **Credit packages**: add package credits only.
4. **Credit invoices**: expire automatically and are cancellable by users.
5. **Account status** only changes on **subscription invoices**.
---
## 8) Operational Verification Checklist
- Stripe/PayPal subscription purchase activates account and resets credits.
- Bank transfer subscription approval resets credits (not adds).
- Credit package approval adds package credits (not plan credits).
- Credit invoices do not block subscription state.
- Credit invoice expiry and cancellation work with emails.
- Renewal resets credits to 0 at cycle end and to full on payment.
---
## 9) Extensibility (Future Addons)
- Add new `Product` types without code changes to invoice fulfillment.
- Use `invoice_type='addon'` with line items to provision entitlements.
- No plan/package hardcoding required.
---
## 10) References
- Audit: [docs/audits/BILLING-SYSTEM-AUDIT-20260120.md](docs/audits/BILLING-SYSTEM-AUDIT-20260120.md)
- Refactor Plan: [docs/audits/BILLING-SYSTEM-AUDIT-AND-REFACTOR-PLAN-20260120.md](docs/audits/BILLING-SYSTEM-AUDIT-AND-REFACTOR-PLAN-20260120.md)

View File

@@ -130,13 +130,29 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
return (
<div className="space-y-6">
{balance && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Current Balance</h3>
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Plan Credits</h3>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{(balance?.credits ?? 0).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Available credits</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reset on renewal</p>
</Card>
<Card className="p-6 border-l-4 border-l-success-500">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Bonus Credits</h3>
<div className="text-3xl font-bold text-success-600 dark:text-success-400">
{((balance as any)?.bonus_credits ?? 0).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Never expire</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Total Available</h3>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{((balance as any)?.total_credits ?? (balance?.credits ?? 0)).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Plan + Bonus</p>
</Card>
<Card className="p-6">
@@ -145,21 +161,32 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
{(balance?.plan_credits_per_month ?? 0).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{(balance as any)?.subscription_plan || 'No plan'}
{(balance as any)?.subscription_plan || 'From plan'}
</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Status</h3>
<div className="mt-2">
<Badge variant="light" className="text-lg">
{(balance as any)?.subscription_status || 'No subscription'}
</Badge>
</div>
</Card>
</div>
)}
{/* Info box about credit consumption order */}
{balance && ((balance as any)?.bonus_credits ?? 0) > 0 && (
<Card className="p-4 bg-info-50 dark:bg-info-900/20 border-info-200 dark:border-info-800">
<div className="flex items-start space-x-3">
<div className="text-info-600 dark:text-info-400">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h4 className="text-sm font-medium text-info-800 dark:text-info-200">Credit Consumption Order</h4>
<p className="text-sm text-info-700 dark:text-info-300 mt-1">
Plan credits are used first. Bonus credits are only consumed after plan credits are exhausted.
Bonus credits never expire and are not affected by plan renewals.
</p>
</div>
</div>
</Card>
)}
{usageLimits && (
<Card className="p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Plan Limits</h2>

View File

@@ -25,7 +25,7 @@ import {
} from '../../icons';
import { API_BASE_URL } from '../../services/api';
import { useAuthStore } from '../../store/authStore';
import { subscribeToPlan, getAvailablePaymentMethods } from '../../services/billing.api';
import { subscribeToPlan, getAvailablePaymentMethods, purchaseCredits } from '../../services/billing.api';
interface BankDetails {
bank_name: string;
@@ -38,6 +38,8 @@ interface BankDetails {
interface Invoice {
id: number;
invoice_number: string;
invoice_type?: 'subscription' | 'credit_package' | 'addon' | 'custom';
credit_package_id?: string | number | null;
total?: string;
total_amount?: string;
currency?: string;
@@ -126,6 +128,8 @@ export default function PayInvoiceModal({
const currency = invoice.currency?.toUpperCase() || 'USD';
const planId = invoice.subscription?.plan?.id;
const planSlug = invoice.subscription?.plan?.slug;
const isCreditInvoice = invoice.invoice_type === 'credit_package';
const creditPackageId = invoice.credit_package_id ? String(invoice.credit_package_id) : null;
// Check if user's default method is selected (for showing badge)
const isDefaultMethod = (option: PaymentOption): boolean => {
@@ -181,6 +185,29 @@ export default function PayInvoiceModal({
}, [isOpen, isPakistan, selectedOption, userCountry, bankDetails]);
const handleStripePayment = async () => {
if (isCreditInvoice) {
if (!creditPackageId) {
setError('Unable to process card payment. Credit package not found on invoice. Please contact support.');
return;
}
try {
setLoading(true);
setError('');
const result = await purchaseCredits(creditPackageId, 'stripe', {
return_url: `${window.location.origin}/account/usage?purchase=success`,
cancel_url: `${window.location.origin}/account/usage?purchase=canceled`,
});
window.location.href = result.redirect_url;
} catch (err: any) {
setError(err.message || 'Failed to initiate card payment');
setLoading(false);
}
return;
}
// Use plan slug if available, otherwise fall back to id
const planIdentifier = planSlug || (planId ? String(planId) : null);
@@ -208,6 +235,29 @@ export default function PayInvoiceModal({
};
const handlePayPalPayment = async () => {
if (isCreditInvoice) {
if (!creditPackageId) {
setError('Unable to process PayPal payment. Credit package not found on invoice. Please contact support.');
return;
}
try {
setLoading(true);
setError('');
const result = await purchaseCredits(creditPackageId, 'paypal', {
return_url: `${window.location.origin}/account/usage?purchase=success`,
cancel_url: `${window.location.origin}/account/usage?purchase=canceled`,
});
window.location.href = result.redirect_url;
} catch (err: any) {
setError(err.message || 'Failed to initiate PayPal payment');
setLoading(false);
}
return;
}
// Use plan slug if available, otherwise fall back to id
const planIdentifier = planSlug || (planId ? String(planId) : null);

View File

@@ -368,7 +368,7 @@ export default function SiteDashboard() {
/>
<CreditAvailabilityWidget
availableCredits={balance?.credits_remaining ?? 0}
availableCredits={(balance as any)?.total_credits ?? balance?.credits_remaining ?? 0}
totalCredits={balance?.plan_credits_per_month ?? 0}
usedCredits={balance?.credits_used_this_month ?? 0}
loading={loading}

View File

@@ -921,16 +921,26 @@ export default function PlansAndBillingPage() {
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-brand-700 dark:text-brand-300 mb-1">
<ZapIcon className="w-4 h-4 text-brand-600" />
Credits
Plan Credits
</div>
<div className="text-2xl font-bold text-brand-900 dark:text-white">
{creditBalance?.credits?.toLocaleString() || 0}
</div>
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Available now</div>
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Reset on renewal</div>
</div>
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm border-l-4 border-l-success-500">
<div className="flex items-center gap-2 text-sm text-success-700 dark:text-success-300 mb-1">
<ZapIcon className="w-4 h-4 text-success-600" />
Bonus Credits
</div>
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
{((creditBalance as any)?.bonus_credits || 0).toLocaleString()}
</div>
<div className="text-xs text-success-600 dark:text-success-400 mt-1">Never expire</div>
</div>
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300 mb-1">
@@ -972,6 +982,21 @@ export default function PlansAndBillingPage() {
</div>
</div>
{/* Total Credits Info Box */}
{((creditBalance as any)?.bonus_credits || 0) > 0 && (
<div className="mt-4 p-3 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-success-200 dark:border-success-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Available:</span>
<span className="text-lg font-bold text-brand-700 dark:text-brand-400">
{((creditBalance?.credits || 0) + ((creditBalance as any)?.bonus_credits || 0)).toLocaleString()} credits
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">Plan + Bonus</span>
</div>
</div>
)}
{/* Credit Usage Bar */}
<div className="mt-6 pt-6 border-t border-brand-200/50 dark:border-brand-700/30">
<div className="flex justify-between text-sm mb-2">
@@ -1319,17 +1344,22 @@ export default function PlansAndBillingPage() {
)}
</td>
<td className="px-6 py-3 text-center">
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
<Badge variant="soft" tone={
invoice.status === 'paid' ? 'success' :
invoice.status === 'failed' ? 'error' :
invoice.status === 'overdue' ? 'error' :
'warning'
}>
{invoice.status}
</Badge>
</td>
<td className="px-6 py-3 text-end">
<div className="flex items-center justify-end gap-2">
{invoice.status === 'pending' && (
{['pending', 'overdue', 'failed', 'sent'].includes(invoice.status) && (
<Button
size="sm"
variant="primary"
tone="brand"
tone={invoice.status === 'overdue' || invoice.status === 'failed' ? 'error' : 'brand'}
startIcon={<DollarLineIcon className="w-4 h-4" />}
onClick={() => {
setSelectedInvoice(invoice);

View File

@@ -469,27 +469,49 @@ export default function UsageDashboardPage() {
</Link>
</div>
<div className="grid grid-cols-3 gap-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-4xl font-bold text-brand-700 dark:text-brand-400 mb-1">
<div className="text-3xl font-bold text-brand-700 dark:text-brand-400 mb-1">
{creditBalance?.credits.toLocaleString() || 0}
</div>
<div className="text-sm text-brand-600 dark:text-brand-300">Available Now</div>
<div className="text-sm text-brand-600 dark:text-brand-300">Plan Credits</div>
</div>
<div>
<div className="text-4xl font-bold text-purple-700 dark:text-purple-400 mb-1">
<div className="text-3xl font-bold text-success-600 dark:text-success-400 mb-1">
{((creditBalance as any)?.bonus_credits || 0).toLocaleString()}
</div>
<div className="text-sm text-success-600 dark:text-success-300">Bonus Credits</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Never expire</div>
</div>
<div>
<div className="text-3xl font-bold text-purple-700 dark:text-purple-400 mb-1">
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
</div>
<div className="text-sm text-purple-600 dark:text-purple-300">Used This Month</div>
</div>
<div>
<div className="text-4xl font-bold text-indigo-800 dark:text-white mb-1">
<div className="text-3xl font-bold text-indigo-800 dark:text-white mb-1">
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
</div>
<div className="text-sm text-indigo-600 dark:text-indigo-300">Monthly Allowance</div>
</div>
</div>
{/* Total Available Credits */}
{((creditBalance as any)?.bonus_credits || 0) > 0 && (
<div className="mt-4 p-3 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-brand-200 dark:border-brand-700">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Available</span>
<span className="text-lg font-bold text-brand-700 dark:text-brand-400">
{((creditBalance?.credits || 0) + ((creditBalance as any)?.bonus_credits || 0)).toLocaleString()}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Plan credits are used first, then bonus credits
</p>
</div>
)}
{/* Credit Usage Bar */}
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">

View File

@@ -2087,7 +2087,9 @@ export async function fetchGlobalModuleSettings(): Promise<GlobalModuleSettings>
// Billing API functions
export interface CreditBalance {
credits: number;
credits: number; // Plan credits (reset on renewal)
bonus_credits: number; // Purchased credits (never expire)
total_credits: number; // Sum of plan + bonus credits
plan_credits_per_month: number;
credits_used_this_month: number;
credits_remaining: number;
@@ -2143,6 +2145,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
// Default if response is invalid
return {
credits: 0,
bonus_credits: 0,
total_credits: 0,
plan_credits_per_month: 0,
credits_used_this_month: 0,
credits_remaining: 0,
@@ -2152,6 +2156,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
// Return default balance on error so UI can still render
return {
credits: 0,
bonus_credits: 0,
total_credits: 0,
plan_credits_per_month: 0,
credits_used_this_month: 0,
credits_remaining: 0,