agdar/finance/tasks.py
Marwan Alwali 2f1681b18c update
2025-11-11 13:44:48 +03:00

573 lines
19 KiB
Python

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