agdar/finance/signals.py
2025-11-02 14:35:35 +03:00

427 lines
13 KiB
Python

"""
Finance Django signals for automation.
This module contains signal handlers for finance-related models to automate
invoice and payment workflow, notifications, and status updates.
"""
import logging
from decimal import Decimal
from django.db.models import Sum
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from core.tasks import create_notification_task
from finance.models import Invoice, Payment
from finance.tasks import send_invoice_notification, send_overdue_reminder, send_payment_receipt
logger = logging.getLogger(__name__)
# ============================================================================
# ID Generation Helpers
# ============================================================================
def generate_invoice_number(tenant):
"""
Generate unique Invoice Number.
Format: INV-YYYY-NNNNNN
"""
current_year = timezone.now().year
# Get last invoice for this tenant in current year
last_invoice = Invoice.objects.filter(
tenant=tenant,
created_at__year=current_year
).order_by('-created_at').first()
if last_invoice and last_invoice.invoice_number:
try:
last_number = int(last_invoice.invoice_number.split('-')[-1])
new_number = last_number + 1
except (ValueError, IndexError):
new_number = 1
else:
new_number = 1
return f"INV-{current_year}-{new_number:06d}"
# ============================================================================
# Invoice Signals
# ============================================================================
@receiver(pre_save, sender=Invoice)
def invoice_pre_save(sender, instance, **kwargs):
"""
Handle pre-save actions for Invoice model.
Actions:
- Auto-generate invoice number if not set
- Track status changes for post-save processing
- Calculate totals if not set
"""
# Auto-generate invoice number
if not instance.invoice_number:
instance.invoice_number = generate_invoice_number(instance.tenant)
logger.info(f"Generated Invoice Number: {instance.invoice_number}")
if instance.pk:
try:
old_instance = Invoice.objects.get(pk=instance.pk)
instance._old_status = old_instance.status
instance._status_changed = old_instance.status != instance.status
except Invoice.DoesNotExist:
instance._old_status = None
instance._status_changed = False
else:
instance._old_status = None
instance._status_changed = True
@receiver(post_save, sender=Invoice)
def invoice_post_save(sender, instance, created, **kwargs):
"""
Handle post-save actions for Invoice model.
Actions:
- Send notification when invoice is issued
- Schedule overdue reminder when invoice becomes overdue
- Notify finance team of new invoices
"""
if created:
# New invoice created
logger.info(
f"Invoice created: {instance.invoice_number} for patient {instance.patient.mrn}"
)
# Notify finance team
# TODO: Get finance team users and notify them
elif hasattr(instance, '_status_changed') and instance._status_changed:
# Status changed
old_status = instance._old_status
new_status = instance.status
logger.info(
f"Invoice {instance.invoice_number} status changed: {old_status} -> {new_status}"
)
# Handle status-specific actions
if new_status == 'ISSUED':
handle_invoice_issued(instance)
elif new_status == 'PAID':
handle_invoice_paid(instance)
elif new_status == 'OVERDUE':
handle_invoice_overdue(instance)
elif new_status == 'CANCELLED':
handle_invoice_cancelled(instance)
def handle_invoice_issued(invoice: Invoice):
"""
Handle invoice issuance.
Actions:
- Send invoice notification to patient
- Notify finance team
"""
# Send invoice to patient
send_invoice_notification.delay(str(invoice.id))
# Notify finance team
# TODO: Implement finance team notification
logger.info(f"Invoice issued: {invoice.invoice_number}")
def handle_invoice_paid(invoice: Invoice):
"""
Handle invoice payment completion.
Actions:
- Send payment confirmation to patient
- Update statistics
"""
# Send confirmation to patient
if invoice.patient.email:
from core.tasks import send_email_task
send_email_task.delay(
subject=f"Invoice Paid - {invoice.invoice_number}",
message=f"Dear {invoice.patient.full_name_en},\n\n"
f"Your invoice {invoice.invoice_number} has been paid in full.\n\n"
f"Amount: {invoice.total}\n\n"
f"Thank you for your payment.\n\n"
f"Best regards,\nTenhal Healthcare Team",
recipient_list=[invoice.patient.email],
)
logger.info(f"Invoice paid: {invoice.invoice_number}")
def handle_invoice_overdue(invoice: Invoice):
"""
Handle invoice becoming overdue.
Actions:
- Send overdue reminder to patient
- Notify finance team
"""
# Send overdue reminder
send_overdue_reminder.delay(str(invoice.id))
# Notify finance team
# TODO: Implement finance team notification
logger.warning(f"Invoice overdue: {invoice.invoice_number}")
def handle_invoice_cancelled(invoice: Invoice):
"""
Handle invoice cancellation.
Actions:
- Notify patient
- Notify finance team
"""
# Notify patient
if invoice.patient.email:
from core.tasks import send_email_task
send_email_task.delay(
subject=f"Invoice Cancelled - {invoice.invoice_number}",
message=f"Dear {invoice.patient.full_name_en},\n\n"
f"Invoice {invoice.invoice_number} has been cancelled.\n\n"
f"If you have any questions, please contact us.\n\n"
f"Best regards,\nTenhal Healthcare Team",
recipient_list=[invoice.patient.email],
)
logger.info(f"Invoice cancelled: {invoice.invoice_number}")
# ============================================================================
# Payment Signals
# ============================================================================
@receiver(pre_save, sender=Payment)
def payment_pre_save(sender, instance, **kwargs):
"""
Handle pre-save actions for Payment model.
Actions:
- Track status changes for post-save processing
"""
if instance.pk:
try:
old_instance = Payment.objects.get(pk=instance.pk)
instance._old_status = old_instance.status
instance._status_changed = old_instance.status != instance.status
except Payment.DoesNotExist:
instance._old_status = None
instance._status_changed = False
else:
instance._old_status = None
instance._status_changed = True
@receiver(post_save, sender=Payment)
def payment_post_save(sender, instance, created, **kwargs):
"""
Handle post-save actions for Payment model.
Actions:
- Update invoice status based on payment
- Send receipt when payment is completed
- Notify relevant parties
"""
if created:
# New payment created
logger.info(
f"Payment created: {instance.id} for invoice {instance.invoice.invoice_number}"
)
# Update invoice status based on total payments
update_invoice_status(instance.invoice)
# Handle status-specific actions
if hasattr(instance, '_status_changed') and instance._status_changed:
old_status = instance._old_status
new_status = instance.status
logger.info(
f"Payment {instance.id} status changed: {old_status} -> {new_status}"
)
if new_status == 'COMPLETED':
handle_payment_completed(instance)
elif new_status == 'FAILED':
handle_payment_failed(instance)
elif new_status == 'REFUNDED':
handle_payment_refunded(instance)
def handle_payment_completed(payment: Payment):
"""
Handle payment completion.
Actions:
- Send receipt to patient
- Update invoice status
- Notify finance team
"""
# Send receipt
send_payment_receipt.delay(str(payment.id))
# Notify finance team
# TODO: Implement finance team notification
logger.info(f"Payment completed: {payment.id}")
def handle_payment_failed(payment: Payment):
"""
Handle payment failure.
Actions:
- Notify patient
- Notify finance team
"""
# Notify patient
if payment.invoice.patient.email:
from core.tasks import send_email_task
send_email_task.delay(
subject="Payment Failed",
message=f"Dear {payment.invoice.patient.full_name_en},\n\n"
f"Your payment for invoice {payment.invoice.invoice_number} has failed.\n\n"
f"Amount: {payment.amount}\n"
f"Payment Method: {payment.get_method_display()}\n\n"
f"Please try again or contact us for assistance.\n\n"
f"Best regards,\nTenhal Healthcare Team",
recipient_list=[payment.invoice.patient.email],
)
logger.warning(f"Payment failed: {payment.id}")
def handle_payment_refunded(payment: Payment):
"""
Handle payment refund.
Actions:
- Notify patient
- Update invoice status
- Notify finance team
"""
# Notify patient
if payment.invoice.patient.email:
from core.tasks import send_email_task
send_email_task.delay(
subject="Payment Refunded",
message=f"Dear {payment.invoice.patient.full_name_en},\n\n"
f"Your payment has been refunded.\n\n"
f"Payment ID: {payment.id}\n"
f"Amount Refunded: {payment.amount}\n"
f"Invoice Number: {payment.invoice.invoice_number}\n\n"
f"The refund will be processed within 5-7 business days.\n\n"
f"Best regards,\nTenhal Healthcare Team",
recipient_list=[payment.invoice.patient.email],
)
# Update invoice status
update_invoice_status(payment.invoice)
logger.info(f"Payment refunded: {payment.id}")
# ============================================================================
# Helper Functions
# ============================================================================
def update_invoice_status(invoice: Invoice):
"""
Update invoice status based on total payments.
Args:
invoice: Invoice instance
"""
# Calculate total payments
total_paid = Payment.objects.filter(
invoice=invoice,
status='COMPLETED'
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
# Update invoice status
old_status = invoice.status
# Get total amount
total_amount = invoice.total
if total_paid >= total_amount:
invoice.status = 'PAID'
elif total_paid > Decimal('0'):
invoice.status = 'PARTIALLY_PAID'
elif invoice.status == 'PAID' and total_paid < total_amount:
# Payment was refunded, revert to issued
invoice.status = 'ISSUED'
if old_status != invoice.status:
invoice.save()
logger.info(
f"Invoice {invoice.invoice_number} status updated: {old_status} -> {invoice.status}"
)
def generate_receipt_number(tenant) -> str:
"""
Generate a unique receipt number.
Args:
tenant: Tenant instance
Returns:
str: Generated receipt number
"""
# Get the last payment for this tenant
last_payment = Payment.objects.filter(
invoice__tenant=tenant
).order_by('-created_at').first()
if last_payment and last_payment.receipt_number:
try:
last_number = int(last_payment.receipt_number.split('-')[-1])
new_number = last_number + 1
except (ValueError, IndexError):
new_number = 1
else:
new_number = 1
# Format: RCP-TENANT-YYYYMMDD-NNNN
date_str = timezone.now().strftime('%Y%m%d')
receipt_number = f"RCP-{tenant.code}-{date_str}-{new_number:04d}"
return receipt_number
# ============================================================================
# Signal Connection Helper
# ============================================================================
def connect_signals():
"""
Explicitly connect all signals.
This function can be called from apps.py to ensure signals are connected.
"""
logger.info("Finance signals connected")