427 lines
13 KiB
Python
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")
|