""" Finance Celery tasks for background processing. This module contains tasks for invoice generation, payment processing, overdue checking, and financial reporting. """ import logging from datetime import datetime, timedelta from decimal import Decimal from typing import Dict, List from celery import shared_task from django.conf import settings from django.db.models import Sum from django.utils import timezone from core.tasks import create_notification_task, send_email_task from finance.models import Invoice, InvoiceLineItem, Payment logger = logging.getLogger(__name__) @shared_task(bind=True, max_retries=3) def generate_invoice_from_appointment(self, appointment_id: str) -> str: """ Generate an invoice from a completed appointment. Args: appointment_id: UUID of the appointment Returns: str: Invoice ID if created, None if already exists """ try: from appointments.models import Appointment appointment = Appointment.objects.select_related( 'patient', 'provider', 'clinic' ).get(id=appointment_id) # Check if invoice already exists existing_invoice = Invoice.objects.filter( appointment=appointment ).first() if existing_invoice: logger.info(f"Invoice already exists for appointment {appointment_id}") return str(existing_invoice.id) # Create invoice invoice = Invoice.objects.create( tenant=appointment.tenant, patient=appointment.patient, appointment=appointment, invoice_date=timezone.now().date(), due_date=timezone.now().date() + timedelta(days=30), status='DRAFT', currency='SAR', ) # Add line item for appointment service_name = f"{appointment.get_appointment_type_display()} with {appointment.provider.get_full_name()}" # Get service price (you may want to look this up from a Service model) # For now, using a default price unit_price = Decimal('200.00') # Default price InvoiceLineItem.objects.create( invoice=invoice, description=service_name, quantity=1, unit_price=unit_price, total=unit_price, ) # Update invoice totals invoice.subtotal = unit_price invoice.total_amount = unit_price invoice.save() logger.info(f"Invoice generated: {invoice.id} for appointment {appointment_id}") # Send notification send_invoice_notification.delay(str(invoice.id)) return str(invoice.id) except Exception as exc: logger.error(f"Failed to generate invoice for appointment {appointment_id}: {exc}") raise self.retry(exc=exc, countdown=300) @shared_task(bind=True, max_retries=3) def send_invoice_notification(self, invoice_id: str) -> bool: """ Send invoice notification to patient. Args: invoice_id: UUID of the invoice Returns: bool: True if notification was sent successfully """ try: invoice = Invoice.objects.select_related('patient').get(id=invoice_id) # Prepare invoice message message = ( f"Dear {invoice.patient.get_full_name()},\n\n" f"An invoice has been generated for your recent visit.\n\n" f"Invoice Number: {invoice.invoice_number}\n" f"Invoice Date: {invoice.invoice_date.strftime('%B %d, %Y')}\n" f"Due Date: {invoice.due_date.strftime('%B %d, %Y')}\n" f"Amount: {invoice.currency} {invoice.total_amount}\n\n" f"Please make payment by the due date.\n\n" f"Best regards,\nTenhal Healthcare Team" ) # Send email if invoice.patient.email: send_email_task.delay( subject=f"Invoice {invoice.invoice_number}", message=message, recipient_list=[invoice.patient.email], ) # Create in-app notification if hasattr(invoice.patient, 'user'): create_notification_task.delay( user_id=str(invoice.patient.user.id), title="New Invoice", message=f"Invoice {invoice.invoice_number} for {invoice.currency} {invoice.total_amount}", notification_type='INFO', related_object_type='invoice', related_object_id=str(invoice.id), ) logger.info(f"Invoice notification sent: {invoice_id}") return True except Invoice.DoesNotExist: logger.error(f"Invoice {invoice_id} not found") return False except Exception as exc: logger.error(f"Failed to send invoice notification for {invoice_id}: {exc}") raise self.retry(exc=exc, countdown=300) @shared_task def check_overdue_invoices() -> int: """ Check for overdue invoices and send reminders. This task runs daily at 9:00 AM to check for overdue invoices and update their status. Returns: int: Number of overdue invoices found """ today = timezone.now().date() # Get invoices that are past due overdue_invoices = Invoice.objects.filter( status__in=['ISSUED'], due_date__lt=today ) count = 0 for invoice in overdue_invoices: # Update status to overdue invoice.status = 'OVERDUE' invoice.save() # Send overdue reminder send_overdue_reminder.delay(str(invoice.id)) count += 1 if count > 0: logger.info(f"Found {count} overdue invoices") # Send alert to Finance Manager send_finance_manager_alert.delay( alert_type='overdue_invoices', count=count ) return count @shared_task(bind=True, max_retries=3) def send_overdue_reminder(self, invoice_id: str) -> bool: """ Send overdue invoice reminder to patient. Args: invoice_id: UUID of the invoice Returns: bool: True if reminder was sent successfully """ try: invoice = Invoice.objects.select_related('patient').get(id=invoice_id) days_overdue = (timezone.now().date() - invoice.due_date).days message = ( f"Dear {invoice.patient.get_full_name()},\n\n" f"This is a reminder that invoice {invoice.invoice_number} is now {days_overdue} days overdue.\n\n" f"Invoice Date: {invoice.invoice_date.strftime('%B %d, %Y')}\n" f"Due Date: {invoice.due_date.strftime('%B %d, %Y')}\n" f"Amount Due: {invoice.currency} {invoice.total_amount}\n\n" f"Please make payment as soon as possible to avoid any late fees.\n\n" f"Best regards,\nTenhal Healthcare Team" ) # Send email if invoice.patient.email: send_email_task.delay( subject=f"Overdue Invoice Reminder - {invoice.invoice_number}", message=message, recipient_list=[invoice.patient.email], ) logger.info(f"Overdue reminder sent for invoice {invoice_id}") return True except Invoice.DoesNotExist: logger.error(f"Invoice {invoice_id} not found") return False except Exception as exc: logger.error(f"Failed to send overdue reminder for {invoice_id}: {exc}") raise self.retry(exc=exc, countdown=300) @shared_task(bind=True, max_retries=3) def send_payment_receipt(self, payment_id: str) -> bool: """ Send payment receipt to patient. Args: payment_id: UUID of the payment Returns: bool: True if receipt was sent successfully """ try: payment = Payment.objects.select_related( 'invoice', 'invoice__patient' ).get(id=payment_id) message = ( f"Dear {payment.invoice.patient.get_full_name()},\n\n" f"Thank you for your payment.\n\n" f"Payment Receipt\n" f"---------------\n" f"Receipt Number: {payment.receipt_number}\n" f"Payment Date: {payment.payment_date.strftime('%B %d, %Y')}\n" f"Amount Paid: {payment.currency} {payment.amount}\n" f"Payment Method: {payment.get_payment_method_display()}\n" f"Invoice Number: {payment.invoice.invoice_number}\n\n" f"Best regards,\nTenhal Healthcare Team" ) # Send email if payment.invoice.patient.email: send_email_task.delay( subject=f"Payment Receipt - {payment.receipt_number}", message=message, recipient_list=[payment.invoice.patient.email], ) logger.info(f"Payment receipt sent: {payment_id}") return True except Payment.DoesNotExist: logger.error(f"Payment {payment_id} not found") return False except Exception as exc: logger.error(f"Failed to send payment receipt for {payment_id}: {exc}") raise self.retry(exc=exc, countdown=300) @shared_task(bind=True, max_retries=3) def send_finance_manager_alert(self, alert_type: str, **kwargs) -> bool: """ Send alert to Finance Manager. Args: alert_type: Type of alert ('overdue_invoices', 'daily_summary', 'unpaid_invoices') **kwargs: Additional alert data Returns: bool: True if alert was sent successfully """ try: from core.models import User # Get all Finance Managers and Admins finance_users = User.objects.filter( role__in=[User.Role.FINANCE, User.Role.ADMIN], is_active=True ) if not finance_users.exists(): logger.warning("No finance managers found to send alert") return False # Prepare alert message based on type if alert_type == 'overdue_invoices': count = kwargs.get('count', 0) title = "Overdue Invoices Alert" message = f"{count} invoice(s) are now overdue and require attention." elif alert_type == 'daily_summary': summary = kwargs.get('summary', {}) title = "Daily Financial Summary" message = ( f"Daily Summary for {summary.get('date', 'today')}:\n" f"Invoices: {summary.get('invoices', {}).get('count', 0)}\n" f"Total Invoiced: SAR {summary.get('invoices', {}).get('total_amount', 0)}\n" f"Payments: {summary.get('payments', {}).get('count', 0)}\n" f"Total Collected: SAR {summary.get('payments', {}).get('total_amount', 0)}" ) elif alert_type == 'unpaid_invoices': count = kwargs.get('count', 0) amount = kwargs.get('amount', 0) title = "Unpaid Invoices Report" message = f"{count} unpaid invoice(s) totaling SAR {amount} require follow-up." else: title = "Finance Alert" message = kwargs.get('message', 'Financial alert notification') # Send notifications to all finance users for user in finance_users: # Create in-app notification create_notification_task.delay( user_id=str(user.id), title=title, message=message, notification_type='WARNING' if alert_type == 'overdue_invoices' else 'INFO', related_object_type='finance', related_object_id=None, ) # Send email if user has email if user.email: send_email_task.delay( subject=title, message=message, recipient_list=[user.email], ) logger.info(f"Finance manager alert sent: {alert_type}") return True except Exception as exc: logger.error(f"Failed to send finance manager alert: {exc}") raise self.retry(exc=exc, countdown=300) @shared_task def send_daily_finance_summary() -> bool: """ Send daily financial summary to Finance Manager. This task runs daily at 6:00 PM to send end-of-day summary. Returns: bool: True if summary was sent successfully """ from finance.reports_service import FinancialReportsService from core.models import Tenant # Get all tenants tenants = Tenant.objects.filter(is_active=True) for tenant in tenants: try: # Generate daily summary today = timezone.now().date() summary = FinancialReportsService.get_daily_summary(tenant, today) # Send alert to Finance Manager send_finance_manager_alert.delay( alert_type='daily_summary', summary=summary ) logger.info(f"Daily finance summary sent for tenant {tenant.name}") except Exception as e: logger.error(f"Failed to send daily summary for tenant {tenant.name}: {e}") return True @shared_task def check_unpaid_invoices() -> int: """ Check for unpaid invoices and send weekly report to Finance Manager. This task runs weekly on Monday at 9:00 AM. Returns: int: Number of unpaid invoices found """ from core.models import Tenant total_count = 0 # Get all tenants tenants = Tenant.objects.filter(is_active=True) for tenant in tenants: # Get all unpaid invoices unpaid_invoices = Invoice.objects.filter( tenant=tenant, status__in=[Invoice.Status.ISSUED, Invoice.Status.PARTIALLY_PAID, Invoice.Status.OVERDUE] ) count = unpaid_invoices.count() total_amount = unpaid_invoices.aggregate(Sum('total'))['total__sum'] or Decimal('0') if count > 0: # Send alert to Finance Manager send_finance_manager_alert.delay( alert_type='unpaid_invoices', count=count, amount=total_amount ) total_count += count logger.info(f"Found {count} unpaid invoices for tenant {tenant.name}") return total_count @shared_task def generate_financial_report(period: str = 'daily', date: str = None) -> Dict: """ Generate financial report for a specific period. Args: period: Report period ('daily', 'weekly', 'monthly') date: Date string in YYYY-MM-DD format (defaults to today) Returns: dict: Financial report data """ if date: target_date = datetime.strptime(date, '%Y-%m-%d').date() else: target_date = timezone.now().date() # Determine date range based on period if period == 'daily': start_date = target_date end_date = target_date elif period == 'weekly': start_date = target_date - timedelta(days=target_date.weekday()) end_date = start_date + timedelta(days=6) elif period == 'monthly': start_date = target_date.replace(day=1) # Get last day of month if target_date.month == 12: end_date = target_date.replace(day=31) else: end_date = (target_date.replace(month=target_date.month + 1, day=1) - timedelta(days=1)) else: start_date = target_date end_date = target_date # Get invoices for the period invoices = Invoice.objects.filter( invoice_date__gte=start_date, invoice_date__lte=end_date ) # Get payments for the period payments = Payment.objects.filter( payment_date__gte=start_date, payment_date__lte=end_date, status='COMPLETED' ) # Calculate statistics report = { 'period': period, 'start_date': str(start_date), 'end_date': str(end_date), 'invoices': { 'total_count': invoices.count(), 'total_amount': invoices.aggregate(total=Sum('total_amount'))['total'] or Decimal('0'), 'by_status': { 'draft': invoices.filter(status='DRAFT').count(), 'issued': invoices.filter(status='ISSUED').count(), 'paid': invoices.filter(status='PAID').count(), 'overdue': invoices.filter(status='OVERDUE').count(), 'cancelled': invoices.filter(status='CANCELLED').count(), } }, 'payments': { 'total_count': payments.count(), 'total_amount': payments.aggregate(total=Sum('amount'))['total'] or Decimal('0'), 'by_method': { 'cash': payments.filter(payment_method='CASH').aggregate(total=Sum('amount'))['total'] or Decimal('0'), 'card': payments.filter(payment_method='CARD').aggregate(total=Sum('amount'))['total'] or Decimal('0'), 'bank_transfer': payments.filter(payment_method='BANK_TRANSFER').aggregate(total=Sum('amount'))['total'] or Decimal('0'), 'insurance': payments.filter(payment_method='INSURANCE').aggregate(total=Sum('amount'))['total'] or Decimal('0'), } }, 'outstanding': { 'count': invoices.filter(status__in=['ISSUED', 'OVERDUE']).count(), 'amount': invoices.filter(status__in=['ISSUED', 'OVERDUE']).aggregate(total=Sum('total_amount'))['total'] or Decimal('0'), } } logger.info(f"Generated {period} financial report for {start_date} to {end_date}") # TODO: Send report via email to finance team return report @shared_task(bind=True, max_retries=3) def process_batch_payments(self, payment_ids: List[str]) -> Dict: """ Process multiple payments in batch. Args: payment_ids: List of payment UUIDs Returns: dict: Processing results """ results = { 'processed': 0, 'failed': 0, 'errors': [] } for payment_id in payment_ids: try: payment = Payment.objects.get(id=payment_id) if payment.status == 'PENDING': payment.status = 'COMPLETED' payment.processed_at = timezone.now() payment.save() # Send receipt send_payment_receipt.delay(payment_id) results['processed'] += 1 else: results['errors'].append(f"Payment {payment_id} is not pending") results['failed'] += 1 except Payment.DoesNotExist: results['errors'].append(f"Payment {payment_id} not found") results['failed'] += 1 except Exception as exc: results['errors'].append(f"Payment {payment_id}: {str(exc)}") results['failed'] += 1 logger.info(f"Batch payment processing: {results['processed']} processed, {results['failed']} failed") return results