payemnt billing and credits refactoring
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user