""" 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")