docs and billing adn acaoutn 40%
This commit is contained in:
249
backend/igny8_core/business/billing/services/invoice_service.py
Normal file
249
backend/igny8_core/business/billing/services/invoice_service.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Invoice Service - Handles invoice creation, management, and PDF generation
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import Invoice, CreditPackage
|
||||
from ....auth.models import Account, Subscription
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
"""Service for managing invoices"""
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_number(account: Account) -> str:
|
||||
"""
|
||||
Generate unique invoice number
|
||||
Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
|
||||
"""
|
||||
now = timezone.now()
|
||||
prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
|
||||
|
||||
# Get count of invoices for this account this month
|
||||
count = Invoice.objects.filter(
|
||||
account=account,
|
||||
created_at__year=now.year,
|
||||
created_at__month=now.month
|
||||
).count()
|
||||
|
||||
return f"{prefix}-{count + 1:04d}"
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_subscription_invoice(
|
||||
subscription: Subscription,
|
||||
billing_period_start: datetime,
|
||||
billing_period_end: datetime
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create invoice for subscription billing period
|
||||
"""
|
||||
account = subscription.account
|
||||
plan = subscription.plan
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
subscription=subscription,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
|
||||
status='pending',
|
||||
currency='USD',
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end
|
||||
)
|
||||
|
||||
# Add line item for subscription
|
||||
invoice.add_line_item(
|
||||
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
|
||||
quantity=1,
|
||||
unit_price=plan.price,
|
||||
amount=plan.price
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
invoice.save()
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_credit_package_invoice(
|
||||
account: Account,
|
||||
credit_package: CreditPackage
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create invoice for credit package purchase
|
||||
"""
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
|
||||
status='pending',
|
||||
currency='USD'
|
||||
)
|
||||
|
||||
# Add line item for credit package
|
||||
invoice.add_line_item(
|
||||
description=f"{credit_package.name} - {credit_package.credits:,} Credits",
|
||||
quantity=1,
|
||||
unit_price=credit_package.price,
|
||||
amount=credit_package.price
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
invoice.save()
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_custom_invoice(
|
||||
account: Account,
|
||||
line_items: List[Dict],
|
||||
billing_email: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
due_date: Optional[datetime] = None
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create custom invoice with multiple line items
|
||||
|
||||
Args:
|
||||
account: Account to bill
|
||||
line_items: List of dicts with keys: description, quantity, unit_price
|
||||
billing_email: Override billing email
|
||||
notes: Invoice notes
|
||||
due_date: Payment due date
|
||||
"""
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
billing_email=billing_email or account.billing_email or account.users.filter(role='owner').first().email,
|
||||
status='draft',
|
||||
currency='USD',
|
||||
notes=notes,
|
||||
due_date=due_date or (timezone.now() + timedelta(days=30))
|
||||
)
|
||||
|
||||
# Add all line items
|
||||
for item in line_items:
|
||||
invoice.add_line_item(
|
||||
description=item['description'],
|
||||
quantity=item.get('quantity', 1),
|
||||
unit_price=Decimal(str(item['unit_price'])),
|
||||
amount=Decimal(str(item.get('amount', item['quantity'] * item['unit_price'])))
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
invoice.save()
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def mark_paid(
|
||||
invoice: Invoice,
|
||||
payment_method: str,
|
||||
transaction_id: Optional[str] = None
|
||||
) -> Invoice:
|
||||
"""
|
||||
Mark invoice as paid
|
||||
"""
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def mark_void(invoice: Invoice, reason: Optional[str] = None) -> Invoice:
|
||||
"""
|
||||
Void an invoice
|
||||
"""
|
||||
if invoice.status == 'paid':
|
||||
raise ValueError("Cannot void a paid invoice")
|
||||
|
||||
invoice.status = 'void'
|
||||
if reason:
|
||||
invoice.notes = f"{invoice.notes}\n\nVoided: {reason}" if invoice.notes else f"Voided: {reason}"
|
||||
invoice.save()
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
def generate_pdf(invoice: Invoice) -> bytes:
|
||||
"""
|
||||
Generate PDF for invoice
|
||||
|
||||
TODO: Implement PDF generation using reportlab or weasyprint
|
||||
For now, return placeholder
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
# Placeholder - implement PDF generation
|
||||
buffer = BytesIO()
|
||||
|
||||
# Simple text representation for now
|
||||
content = f"""
|
||||
INVOICE #{invoice.invoice_number}
|
||||
|
||||
Bill To: {invoice.account.name}
|
||||
Email: {invoice.billing_email}
|
||||
|
||||
Date: {invoice.created_at.strftime('%Y-%m-%d')}
|
||||
Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'}
|
||||
|
||||
Line Items:
|
||||
"""
|
||||
for item in invoice.line_items:
|
||||
content += f" {item['description']} - ${item['amount']}\n"
|
||||
|
||||
content += f"""
|
||||
Subtotal: ${invoice.subtotal}
|
||||
Tax: ${invoice.tax_amount}
|
||||
Total: ${invoice.total_amount}
|
||||
|
||||
Status: {invoice.status.upper()}
|
||||
"""
|
||||
|
||||
buffer.write(content.encode('utf-8'))
|
||||
buffer.seek(0)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def get_account_invoices(
|
||||
account: Account,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> List[Invoice]:
|
||||
"""
|
||||
Get invoices for an account
|
||||
"""
|
||||
queryset = Invoice.objects.filter(account=account)
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return list(queryset.order_by('-created_at')[:limit])
|
||||
|
||||
@staticmethod
|
||||
def get_upcoming_renewals(days: int = 7) -> List[Subscription]:
|
||||
"""
|
||||
Get subscriptions that will renew in the next N days
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() + timedelta(days=days)
|
||||
|
||||
return list(
|
||||
Subscription.objects.filter(
|
||||
status='active',
|
||||
current_period_end__lte=cutoff_date
|
||||
).select_related('account', 'plan')
|
||||
)
|
||||
375
backend/igny8_core/business/billing/services/payment_service.py
Normal file
375
backend/igny8_core/business/billing/services/payment_service.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Payment Service - Handles payment processing across multiple gateways
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import Payment, Invoice, CreditPackage, PaymentMethodConfig, CreditTransaction
|
||||
from ....auth.models import Account
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""Service for processing payments across multiple gateways"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_stripe_payment(
|
||||
invoice: Invoice,
|
||||
stripe_payment_intent_id: str,
|
||||
stripe_charge_id: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Create payment record for Stripe transaction
|
||||
"""
|
||||
payment = Payment.objects.create(
|
||||
account=invoice.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method='stripe',
|
||||
status='pending',
|
||||
stripe_payment_intent_id=stripe_payment_intent_id,
|
||||
stripe_charge_id=stripe_charge_id,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_paypal_payment(
|
||||
invoice: Invoice,
|
||||
paypal_order_id: str,
|
||||
metadata: Optional[Dict] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Create payment record for PayPal transaction
|
||||
"""
|
||||
payment = Payment.objects.create(
|
||||
account=invoice.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method='paypal',
|
||||
status='pending',
|
||||
paypal_order_id=paypal_order_id,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_manual_payment(
|
||||
invoice: Invoice,
|
||||
payment_method: str, # 'bank_transfer' or 'local_wallet'
|
||||
transaction_reference: str,
|
||||
admin_notes: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Create manual payment (bank transfer or local wallet)
|
||||
Requires admin approval
|
||||
"""
|
||||
if payment_method not in ['bank_transfer', 'local_wallet', 'manual']:
|
||||
raise ValueError("Invalid manual payment method")
|
||||
|
||||
payment = Payment.objects.create(
|
||||
account=invoice.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method=payment_method,
|
||||
status='pending_approval',
|
||||
transaction_reference=transaction_reference,
|
||||
admin_notes=admin_notes,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def mark_payment_completed(
|
||||
payment: Payment,
|
||||
transaction_id: Optional[str] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Mark payment as completed and update invoice
|
||||
"""
|
||||
from .invoice_service import InvoiceService
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.processed_at = timezone.now()
|
||||
|
||||
if transaction_id:
|
||||
payment.transaction_reference = transaction_id
|
||||
|
||||
payment.save()
|
||||
|
||||
# Update invoice
|
||||
if payment.invoice:
|
||||
InvoiceService.mark_paid(
|
||||
payment.invoice,
|
||||
payment_method=payment.payment_method,
|
||||
transaction_id=transaction_id
|
||||
)
|
||||
|
||||
# If payment is for credit package, add credits to account
|
||||
if payment.metadata.get('credit_package_id'):
|
||||
PaymentService._add_credits_for_payment(payment)
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def mark_payment_failed(
|
||||
payment: Payment,
|
||||
failure_reason: Optional[str] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Mark payment as failed
|
||||
"""
|
||||
payment.status = 'failed'
|
||||
payment.failure_reason = failure_reason
|
||||
payment.processed_at = timezone.now()
|
||||
payment.save()
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def approve_manual_payment(
|
||||
payment: Payment,
|
||||
approved_by_user_id: int,
|
||||
admin_notes: Optional[str] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Approve manual payment (admin action)
|
||||
"""
|
||||
if payment.status != 'pending_approval':
|
||||
raise ValueError("Payment is not pending approval")
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.processed_at = timezone.now()
|
||||
payment.approved_by_id = approved_by_user_id
|
||||
|
||||
if admin_notes:
|
||||
payment.admin_notes = f"{payment.admin_notes}\n\nApproval notes: {admin_notes}" if payment.admin_notes else admin_notes
|
||||
|
||||
payment.save()
|
||||
|
||||
# Update invoice
|
||||
if payment.invoice:
|
||||
from .invoice_service import InvoiceService
|
||||
InvoiceService.mark_paid(
|
||||
payment.invoice,
|
||||
payment_method=payment.payment_method,
|
||||
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)
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def reject_manual_payment(
|
||||
payment: Payment,
|
||||
rejected_by_user_id: int,
|
||||
rejection_reason: str
|
||||
) -> Payment:
|
||||
"""
|
||||
Reject manual payment (admin action)
|
||||
"""
|
||||
if payment.status != 'pending_approval':
|
||||
raise ValueError("Payment is not pending approval")
|
||||
|
||||
payment.status = 'failed'
|
||||
payment.failure_reason = rejection_reason
|
||||
payment.processed_at = timezone.now()
|
||||
payment.admin_notes = f"{payment.admin_notes}\n\nRejected by user {rejected_by_user_id}: {rejection_reason}" if payment.admin_notes else f"Rejected: {rejection_reason}"
|
||||
payment.save()
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
def _add_credits_for_payment(payment: Payment) -> None:
|
||||
"""
|
||||
Add credits to account after successful payment
|
||||
"""
|
||||
credit_package_id = payment.metadata.get('credit_package_id')
|
||||
if not credit_package_id:
|
||||
return
|
||||
|
||||
try:
|
||||
credit_package = CreditPackage.objects.get(id=credit_package_id)
|
||||
except CreditPackage.DoesNotExist:
|
||||
return
|
||||
|
||||
# Create credit transaction
|
||||
CreditTransaction.objects.create(
|
||||
account=payment.account,
|
||||
amount=credit_package.credits,
|
||||
transaction_type='purchase',
|
||||
description=f"Purchased {credit_package.name}",
|
||||
reference_id=str(payment.id),
|
||||
metadata={
|
||||
'payment_id': payment.id,
|
||||
'credit_package_id': credit_package_id,
|
||||
'invoice_id': payment.invoice_id if payment.invoice else None
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_available_payment_methods(account: Account) -> Dict[str, Any]:
|
||||
"""
|
||||
Get available payment methods for account's country
|
||||
"""
|
||||
country_code = account.billing_country or 'US'
|
||||
|
||||
# Get payment method configurations for country
|
||||
configs = PaymentMethodConfig.objects.filter(
|
||||
country_code=country_code,
|
||||
is_enabled=True
|
||||
).order_by('sort_order')
|
||||
|
||||
# Default methods if no config
|
||||
if not configs.exists():
|
||||
return {
|
||||
'methods': [
|
||||
{
|
||||
'type': 'stripe',
|
||||
'name': 'Credit/Debit Card',
|
||||
'instructions': 'Pay securely with your credit or debit card'
|
||||
},
|
||||
{
|
||||
'type': 'paypal',
|
||||
'name': 'PayPal',
|
||||
'instructions': 'Pay with your PayPal account'
|
||||
}
|
||||
],
|
||||
'stripe': True,
|
||||
'paypal': True,
|
||||
'bank_transfer': False,
|
||||
'local_wallet': False
|
||||
}
|
||||
|
||||
# Build response from configs
|
||||
methods = []
|
||||
method_flags = {
|
||||
'stripe': False,
|
||||
'paypal': False,
|
||||
'bank_transfer': False,
|
||||
'local_wallet': False
|
||||
}
|
||||
|
||||
for config in configs:
|
||||
method_flags[config.payment_method] = True
|
||||
method_data = {
|
||||
'type': config.payment_method,
|
||||
'name': config.display_name or config.get_payment_method_display(),
|
||||
'instructions': config.instructions
|
||||
}
|
||||
|
||||
# Add bank details if bank_transfer
|
||||
if config.payment_method == 'bank_transfer' and config.bank_name:
|
||||
method_data['bank_details'] = {
|
||||
'bank_name': config.bank_name,
|
||||
'account_number': config.account_number,
|
||||
'routing_number': config.routing_number,
|
||||
'swift_code': config.swift_code
|
||||
}
|
||||
|
||||
# Add wallet details if local_wallet
|
||||
if config.payment_method == 'local_wallet' and config.wallet_type:
|
||||
method_data['wallet_details'] = {
|
||||
'wallet_type': config.wallet_type,
|
||||
'wallet_id': config.wallet_id
|
||||
}
|
||||
|
||||
methods.append(method_data)
|
||||
|
||||
return {
|
||||
'methods': methods,
|
||||
**method_flags
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_pending_approvals() -> list:
|
||||
"""
|
||||
Get all payments pending admin approval
|
||||
"""
|
||||
return list(
|
||||
Payment.objects.filter(
|
||||
status='pending_approval'
|
||||
).select_related('account', 'invoice').order_by('-created_at')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def refund_payment(
|
||||
payment: Payment,
|
||||
amount: Optional[Decimal] = None,
|
||||
reason: Optional[str] = None
|
||||
) -> Payment:
|
||||
"""
|
||||
Process refund for a payment
|
||||
|
||||
TODO: Implement actual refund logic for Stripe/PayPal
|
||||
For now, just mark as refunded
|
||||
"""
|
||||
if payment.status != 'completed':
|
||||
raise ValueError("Can only refund completed payments")
|
||||
|
||||
refund_amount = amount or payment.amount
|
||||
|
||||
if refund_amount > payment.amount:
|
||||
raise ValueError("Refund amount cannot exceed payment amount")
|
||||
|
||||
# Create refund payment record
|
||||
refund = Payment.objects.create(
|
||||
account=payment.account,
|
||||
invoice=payment.invoice,
|
||||
amount=-refund_amount, # Negative amount for refund
|
||||
currency=payment.currency,
|
||||
payment_method=payment.payment_method,
|
||||
status='completed',
|
||||
processed_at=timezone.now(),
|
||||
metadata={
|
||||
'refund_for_payment_id': payment.id,
|
||||
'refund_reason': reason,
|
||||
'original_amount': str(payment.amount)
|
||||
}
|
||||
)
|
||||
|
||||
# Update original payment metadata
|
||||
payment.metadata['refunded'] = True
|
||||
payment.metadata['refund_payment_id'] = refund.id
|
||||
payment.metadata['refund_amount'] = str(refund_amount)
|
||||
payment.save()
|
||||
|
||||
return refund
|
||||
|
||||
@staticmethod
|
||||
def get_account_payments(
|
||||
account: Account,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> list:
|
||||
"""
|
||||
Get payment history for account
|
||||
"""
|
||||
queryset = Payment.objects.filter(account=account)
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return list(
|
||||
queryset.select_related('invoice')
|
||||
.order_by('-created_at')[:limit]
|
||||
)
|
||||
Reference in New Issue
Block a user