573 lines
19 KiB
Python
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
|