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

@@ -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,