""" Finance views for the Tenhal Multidisciplinary Healthcare Platform. This module contains views for financial management including: - Invoice CRUD operations - Payment processing - Package management - Payer management - Financial reporting """ from decimal import Decimal from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q, Sum, Count from django.http import JsonResponse, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic import ListView, DetailView, CreateView, UpdateView from django.urls import reverse_lazy from django.contrib import messages from core.mixins import ( TenantFilterMixin, RolePermissionMixin, AuditLogMixin, HTMXResponseMixin, SuccessMessageMixin, PaginationMixin, ) from core.models import User, Patient from .models import * from .forms import * class InvoiceListView(LoginRequiredMixin, TenantFilterMixin, PaginationMixin, HTMXResponseMixin, ListView): """ Invoice list view with filtering and search. Features: - Filter by status, payer, date range - Search by patient name/MRN, invoice number - Summary statistics - Export to CSV """ model = Invoice template_name = 'finance/invoice_list.html' htmx_template_name = 'finance/partials/invoice_list_partial.html' context_object_name = 'invoices' paginate_by = 25 def get_queryset(self): """Get filtered queryset.""" queryset = super().get_queryset() # Apply search search_query = self.request.GET.get('search', '').strip() if search_query: queryset = queryset.filter( Q(patient__first_name_en__icontains=search_query) | Q(patient__last_name_en__icontains=search_query) | Q(patient__mrn__icontains=search_query) | Q(invoice_number__icontains=search_query) ) # Apply filters status = self.request.GET.get('status') if status: queryset = queryset.filter(status=status) payer_id = self.request.GET.get('payer') if payer_id: queryset = queryset.filter(payer_id=payer_id) date_from = self.request.GET.get('date_from') if date_from: queryset = queryset.filter(invoice_date__gte=date_from) date_to = self.request.GET.get('date_to') if date_to: queryset = queryset.filter(issue_date__lte=date_to) # Filter overdue if self.request.GET.get('overdue') == 'true': queryset = queryset.filter( status=Invoice.Status.ISSUED, due_date__lt=timezone.now().date() ) return queryset.select_related('patient', 'payer').order_by('-issue_date') def get_context_data(self, **kwargs): """Add filter options and statistics.""" context = super().get_context_data(**kwargs) # Add filter options context['payers'] = Payer.objects.filter(tenant=self.request.user.tenant) context['status_choices'] = Invoice.Status.choices # Add current filters context['current_filters'] = { 'search': self.request.GET.get('search', ''), 'status': self.request.GET.get('status', ''), 'payer': self.request.GET.get('payer', ''), 'date_from': self.request.GET.get('date_from', ''), 'date_to': self.request.GET.get('date_to', ''), 'overdue': self.request.GET.get('overdue', ''), } # Add summary statistics queryset = self.get_queryset() context['stats'] = { 'total_invoices': queryset.count(), 'total_amount': queryset.aggregate(Sum('total'))['total__sum'] or 0, 'total_paid': sum(inv.amount_paid for inv in queryset), 'pending_amount': sum(inv.amount_due for inv in queryset.filter(status__in=[Invoice.Status.DRAFT, Invoice.Status.ISSUED])), 'overdue_count': queryset.filter( status=Invoice.Status.ISSUED, due_date__lt=timezone.now().date() ).count(), } return context def render_to_response(self, context, **response_kwargs): """Handle CSV export.""" if self.request.GET.get('export') == 'csv': return self._export_to_csv(context['invoices']) return super().render_to_response(context, **response_kwargs) def _export_to_csv(self, invoices): """Export invoices to CSV.""" import csv response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="invoices_{timezone.now().date()}.csv"' writer = csv.writer(response) writer.writerow([ 'Invoice Number', 'Date', 'Patient', 'MRN', 'Payer', 'Total Amount', 'Paid Amount', 'Balance', 'Status', 'Due Date' ]) for invoice in invoices: writer.writerow([ invoice.invoice_number, invoice.issue_date, invoice.patient.full_name_en if hasattr(invoice.patient, 'full_name_en') else str(invoice.patient), invoice.patient.mrn, invoice.payer.name if invoice.payer else 'Self-Pay', invoice.total, invoice.amount_paid, invoice.amount_due, invoice.get_status_display(), invoice.due_date, ]) return response class InvoiceDetailView(LoginRequiredMixin, TenantFilterMixin, DetailView): """ Invoice detail view. Features: - Full invoice details with line items - Payment history - Available actions (send, pay, void) - Print/PDF download """ model = Invoice template_name = 'finance/invoice_detail.html' context_object_name = 'invoice' def get_context_data(self, **kwargs): """Add line items and payments.""" context = super().get_context_data(**kwargs) invoice = self.object # Get line items context['line_items'] = invoice.line_items.all() # Get payments context['payments'] = invoice.payments.all().order_by('-payment_date') # Calculate balance context['balance'] = invoice.amount_due # Check if overdue context['is_overdue'] = ( invoice.status == Invoice.Status.OVERDUE and invoice.due_date < timezone.now().date() ) # Available actions context['can_send'] = invoice.status == Invoice.Status.DRAFT context['can_pay'] = invoice.status in [Invoice.Status.ISSUED, Invoice.Status.PARTIALLY_PAID] context['can_cancel'] = invoice.status not in [Invoice.Status.CANCELLED, Invoice.Status.PAID] return context class InvoiceCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, SuccessMessageMixin, CreateView): """ Invoice creation view. Features: - Auto-generate invoice number - Add line items (services/packages) - Calculate totals - Link to appointment """ model = Invoice form_class = InvoiceForm template_name = 'finance/invoice_form.html' success_message = _("Invoice created successfully! Number: {invoice_number}") allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK, User.Role.FINANCE] def get_initial(self): """Set initial form values from URL parameters.""" initial = super().get_initial() # Pre-populate patient if provided patient_id = self.request.GET.get('patient') if patient_id: try: patient = Patient.objects.get( pk=patient_id, tenant=self.request.user.tenant ) initial['patient'] = patient except Patient.DoesNotExist: pass # Set default dates from datetime import date, timedelta initial['issue_date'] = date.today() initial['due_date'] = date.today() + timedelta(days=30) return initial def get_success_url(self): """Redirect to invoice detail.""" return reverse_lazy('finance:invoice_detail', kwargs={'pk': self.object.pk}) def get_context_data(self, **kwargs): """Add form title, patient info, and line item formset.""" context = super().get_context_data(**kwargs) context['form_title'] = _('Create New Invoice') context['submit_text'] = _('Create Invoice') # Add line item formset if self.request.POST: context['line_item_formset'] = InvoiceLineItemFormSet( self.request.POST, instance=self.object ) else: # Check if we should pre-populate from appointment appointment_id = self.request.GET.get('appointment') initial_data = [] if appointment_id: try: from appointments.models import Appointment appointment = Appointment.objects.get( pk=appointment_id, tenant=self.request.user.tenant ) # Try to find a matching service by name matching_service = Service.objects.filter( tenant=self.request.user.tenant, name_en__icontains=appointment.service_type, is_active=True ).first() if not matching_service: # Try partial match or create a generic line item matching_service = Service.objects.filter( tenant=self.request.user.tenant, clinic=appointment.clinic, is_active=True ).first() # Add initial line item data initial_data.append({ 'service': matching_service, 'description': appointment.service_type, 'quantity': 1, }) except (Appointment.DoesNotExist, Exception): pass context['line_item_formset'] = InvoiceLineItemFormSet( instance=self.object, initial=initial_data if initial_data else None ) # Add services and packages for the item dropdown context['services'] = Service.objects.filter( tenant=self.request.user.tenant, is_active=True ).order_by('name_en') context['packages'] = Package.objects.filter( tenant=self.request.user.tenant, is_active=True ).order_by('name_en') # Get patient if provided in URL or from POST patient_id = self.request.GET.get('patient') or self.request.POST.get('patient') if patient_id: try: patient = Patient.objects.get( pk=patient_id, tenant=self.request.user.tenant ) context['patient'] = patient # Check if patient is Saudi (national_id starts with '1') context['is_saudi_patient'] = ( patient.national_id.startswith('1') if patient.national_id else False ) except Patient.DoesNotExist: context['is_saudi_patient'] = False else: # Default to False (apply VAT) if no patient selected context['is_saudi_patient'] = False # Get appointment if provided in URL appointment_id = self.request.GET.get('appointment') if appointment_id: try: from appointments.models import Appointment appointment = Appointment.objects.get( pk=appointment_id, tenant=self.request.user.tenant ) context['appointment'] = appointment context['appointment_info'] = { 'number': appointment.appointment_number, 'date': appointment.scheduled_date, 'service_type': appointment.service_type, 'clinic': appointment.clinic.name_en, } except Appointment.DoesNotExist: pass # Add all patients with their nationality info for JavaScript import json patients = Patient.objects.filter(tenant=self.request.user.tenant).values('id', 'national_id') patients_nationality = { str(p['id']): p['national_id'].startswith('1') if p['national_id'] else False for p in patients } context['patients_nationality_json'] = json.dumps(patients_nationality) return context def form_valid(self, form): """Set tenant, generate invoice number, check duplicates, and save formset.""" from finance.csid_manager import InvoiceCounterManager from finance.reports_service import DuplicateInvoiceChecker from django.db import transaction # Check for duplicate invoice before creating patient_id = form.cleaned_data.get('patient').id issue_date = form.cleaned_data.get('issue_date') # Calculate estimated total from POST data tax_amount = self.request.POST.get('tax', 0) or 0 discount_amount = self.request.POST.get('discount', 0) or 0 # Calculate subtotal from line items estimated_subtotal = Decimal('0') line_item_count = int(self.request.POST.get('line_items-TOTAL_FORMS', 0)) for i in range(line_item_count): if self.request.POST.get(f'line_items-{i}-DELETE') != 'on': unit_price = self.request.POST.get(f'line_items-{i}-unit_price', 0) or 0 quantity = self.request.POST.get(f'line_items-{i}-quantity', 1) or 1 estimated_subtotal += Decimal(str(unit_price)) * Decimal(str(quantity)) estimated_total = estimated_subtotal + Decimal(str(tax_amount)) - Decimal(str(discount_amount)) # Check for duplicate duplicate = DuplicateInvoiceChecker.check_duplicate( tenant=self.request.user.tenant, patient_id=str(patient_id), issue_date=issue_date, total=estimated_total ) if duplicate: messages.warning( self.request, _( "Warning: A similar invoice already exists (%(invoice_number)s) for this patient " "on the same date with a similar amount. Please verify this is not a duplicate." ) % {'invoice_number': duplicate.invoice_number} ) # Allow creation but warn user # Use database transaction with locking to prevent race conditions with transaction.atomic(): # Set tenant form.instance.tenant = self.request.user.tenant # Generate invoice number if not form.instance.invoice_number: form.instance.invoice_number = self._generate_invoice_number() # Set invoice counter (ZATCA requirement) with SELECT FOR UPDATE lock if not form.instance.invoice_counter: # Lock the last invoice to prevent concurrent counter conflicts last_invoice = Invoice.objects.select_for_update().filter( tenant=self.request.user.tenant ).order_by('-invoice_counter').first() if last_invoice: form.instance.invoice_counter = last_invoice.invoice_counter + 1 form.instance.previous_invoice_hash = last_invoice.invoice_hash else: form.instance.invoice_counter = 1 form.instance.previous_invoice_hash = "" # Set initial status if not form.instance.status: form.instance.status = Invoice.Status.DRAFT # Link to appointment if provided appointment_id = self.request.GET.get('appointment') if appointment_id: try: from appointments.models import Appointment appointment = Appointment.objects.get( pk=appointment_id, tenant=self.request.user.tenant ) form.instance.appointment = appointment except Appointment.DoesNotExist: pass # Set tax and discount from POST data tax_amount = self.request.POST.get('tax', 0) or 0 discount_amount = self.request.POST.get('discount', 0) or 0 form.instance.tax = Decimal(str(tax_amount)) form.instance.discount = Decimal(str(discount_amount)) # Save invoice first (this will trigger hash and QR code generation) self.object = form.save() # Get and validate formset formset = InvoiceLineItemFormSet( self.request.POST, instance=self.object ) if formset.is_valid(): # Save line items with unit_price from POST line_items = formset.save(commit=False) for i, line_item in enumerate(line_items): unit_price = self.request.POST.get(f'line_items-{i}-unit_price', 0) or 0 line_item.unit_price = Decimal(str(unit_price)) line_item.save() # Handle deleted items for obj in formset.deleted_objects: obj.delete() # Calculate totals manually self.object.subtotal = sum(item.total for item in self.object.line_items.all()) self.object.total = self.object.subtotal + self.object.tax - self.object.discount self.object.save() # Update success message self.success_message = self.success_message.format( invoice_number=self.object.invoice_number ) messages.success(self.request, self.success_message) return redirect(self.get_success_url()) else: # If formset is invalid, delete the invoice and show errors self.object.delete() return self.form_invalid(form) def _generate_invoice_number(self): """Generate unique invoice number.""" import random tenant = self.request.user.tenant year = timezone.now().year for _ in range(10): random_num = random.randint(10000, 99999) number = f"INV-{tenant.code}-{year}-{random_num}" if not Invoice.objects.filter(invoice_number=number).exists(): return number # Fallback timestamp = int(timezone.now().timestamp()) return f"INV-{tenant.code}-{year}-{timestamp}" class InvoiceUpdateView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, SuccessMessageMixin, UpdateView): """ Invoice update view. Features: - Update invoice details - Cannot update if paid/void - Recalculate totals """ model = Invoice form_class = InvoiceForm template_name = 'finance/invoice_form.html' success_message = _("Invoice updated successfully!") allowed_roles = [User.Role.ADMIN, User.Role.FINANCE] def get_success_url(self): """Redirect to invoice detail.""" return reverse_lazy('finance:invoice_detail', kwargs={'pk': self.object.pk}) def get_form(self, form_class=None): """Disable fields if invoice is paid/cancelled.""" form = super().get_form(form_class) if self.object.status in [Invoice.Status.PAID, Invoice.Status.CANCELLED]: for field in form.fields: form.fields[field].disabled = True return form def get_context_data(self, **kwargs): """Add form title and line item formset.""" context = super().get_context_data(**kwargs) context['form_title'] = _('Update Invoice: %(number)s') % {'number': self.object.invoice_number} context['submit_text'] = _('Update Invoice') # Add line item formset if self.request.POST: context['line_item_formset'] = InvoiceLineItemFormSet( self.request.POST, instance=self.object ) else: context['line_item_formset'] = InvoiceLineItemFormSet( instance=self.object ) # Add services and packages for the item dropdown context['services'] = Service.objects.filter( tenant=self.request.user.tenant, is_active=True ).order_by('name_en') context['packages'] = Package.objects.filter( tenant=self.request.user.tenant, is_active=True ).order_by('name_en') # Check if patient is Saudi (national_id starts with '1') if self.object.patient: context['is_saudi_patient'] = ( self.object.patient.national_id.startswith('1') if self.object.patient.national_id else False ) return context def form_valid(self, form): """Save invoice and formset.""" # Set tax and discount from POST data tax_amount = self.request.POST.get('tax', 0) or 0 discount_amount = self.request.POST.get('discount', 0) or 0 form.instance.tax = Decimal(str(tax_amount)) form.instance.discount = Decimal(str(discount_amount)) # Save invoice self.object = form.save() # Get and validate formset formset = InvoiceLineItemFormSet( self.request.POST, instance=self.object ) if formset.is_valid(): # Save line items with unit_price from POST line_items = formset.save(commit=False) for i, line_item in enumerate(line_items): unit_price = self.request.POST.get(f'line_items-{i}-unit_price', 0) or 0 line_item.unit_price = Decimal(str(unit_price)) line_item.save() # Handle deleted items for obj in formset.deleted_objects: obj.delete() # Calculate totals manually self.object.subtotal = sum(item.total for item in self.object.line_items.all()) self.object.total = self.object.subtotal + self.object.tax - self.object.discount self.object.save() messages.success(self.request, self.success_message) return redirect(self.get_success_url()) else: # If formset is invalid, show errors return self.form_invalid(form) class PaymentCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, SuccessMessageMixin, CreateView): """ Payment creation view. Features: - Record payment for invoice - Update invoice paid amount - Update invoice status - Generate receipt """ model = Payment form_class = PaymentForm template_name = 'finance/payment_form.html' success_message = _("Payment recorded successfully!") allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK, User.Role.FINANCE] def get_form_kwargs(self): """Pass invoice_id to form if provided in URL.""" kwargs = super().get_form_kwargs() invoice_id = self.request.GET.get('invoice') if invoice_id: kwargs['invoice_id'] = invoice_id return kwargs def get_success_url(self): """Redirect to invoice detail.""" return reverse_lazy('finance:invoice_detail', kwargs={'pk': self.object.invoice.pk}) def form_valid(self, form): """Set tenant and processed_by, then save.""" form.instance.tenant = self.request.user.tenant form.instance.processed_by = self.request.user # Set amount from POST data amount_value = self.request.POST.get('amount', 0) or 0 form.instance.amount = Decimal(str(amount_value)) # Set status to COMPLETED by default (payment is being recorded now) form.instance.status = Payment.Status.COMPLETED # Save payment response = super().form_valid(form) # Update invoice status based on payment invoice = self.object.invoice total_paid = sum(p.amount for p in invoice.payments.filter(status=Payment.Status.COMPLETED)) if total_paid >= invoice.total: invoice.status = Invoice.Status.PAID elif total_paid > 0: invoice.status = Invoice.Status.PARTIALLY_PAID invoice.save() # Generate receipt (TODO) self._generate_receipt() return response def _generate_receipt(self): """Generate payment receipt.""" # TODO: Implement receipt generation pass def get_context_data(self, **kwargs): """Add invoice info.""" context = super().get_context_data(**kwargs) # Get invoice from URL invoice_id = self.request.GET.get('invoice') if invoice_id: try: invoice = Invoice.objects.get( pk=invoice_id, tenant=self.request.user.tenant ) context['invoice'] = invoice context['balance'] = invoice.amount_due except Invoice.DoesNotExist: pass context['form_title'] = _('Record Payment') context['submit_text'] = _('Record Payment') return context class PaymentRefundView(LoginRequiredMixin, RolePermissionMixin, View): """ Handle payment refunds. Features: - Mark payment as refunded - Update invoice status - Record refund details """ allowed_roles = [User.Role.ADMIN, User.Role.FINANCE] def post(self, request, pk): """Process refund.""" payment = get_object_or_404( Payment, pk=pk, invoice__tenant=request.user.tenant ) # Check if payment can be refunded if payment.status != Payment.Status.COMPLETED: messages.error(request, _("Only completed payments can be refunded.")) return redirect('finance:invoice_detail', pk=payment.invoice.pk) # Get refund details from POST refund_reason = request.POST.get('refund_reason', '') # Mark payment as refunded payment.status = Payment.Status.REFUNDED payment.notes = f"REFUNDED: {refund_reason}\n\n{payment.notes}" payment.save() # Update invoice status invoice = payment.invoice total_paid = sum( p.amount for p in invoice.payments.filter(status=Payment.Status.COMPLETED) ) if total_paid <= 0: # No payments left, revert to issued invoice.status = Invoice.Status.ISSUED elif total_paid < invoice.total: invoice.status = Invoice.Status.PARTIALLY_PAID else: invoice.status = Invoice.Status.PAID invoice.save() messages.success(request, _("Payment refunded successfully. Amount: %(amount)s") % {'amount': payment.amount}) return redirect('finance:invoice_detail', pk=invoice.pk) class PaymentListView(LoginRequiredMixin, TenantFilterMixin, PaginationMixin, ListView): """ Payment list view. Features: - List all payments - Filter by date, method, invoice - Summary statistics """ model = Payment template_name = 'finance/payment_list.html' context_object_name = 'payments' paginate_by = 25 def get_queryset(self): """Get filtered queryset.""" queryset = Payment.objects.filter( invoice__tenant=self.request.user.tenant ) # Apply filters date_from = self.request.GET.get('date_from') if date_from: queryset = queryset.filter(payment_date__gte=date_from) date_to = self.request.GET.get('date_to') if date_to: queryset = queryset.filter(payment_date__lte=date_to) method = self.request.GET.get('method') if method: queryset = queryset.filter(payment_method=method) return queryset.select_related('invoice', 'invoice__patient').order_by('-payment_date') def get_context_data(self, **kwargs): """Add statistics.""" context = super().get_context_data(**kwargs) queryset = self.get_queryset() context['stats'] = { 'total_payments': queryset.count(), 'total_amount': queryset.aggregate(Sum('amount'))['amount__sum'] or 0, } context['method_choices'] = Payment.PaymentMethod.choices return context class PackageListView(LoginRequiredMixin, TenantFilterMixin, PaginationMixin, ListView): """ Package list view. Features: - List all service packages - Filter by specialty - Show pricing and sessions """ model = Package template_name = 'finance/package_list.html' context_object_name = 'packages' paginate_by = 25 def get_queryset(self): """Get filtered queryset.""" queryset = super().get_queryset() # Apply filters specialty = self.request.GET.get('specialty') if specialty: queryset = queryset.filter(services__clinic__specialty=specialty) is_active = self.request.GET.get('is_active') if is_active: queryset = queryset.filter(is_active=(is_active == 'true')) return queryset.order_by('services__clinic__specialty', 'name_en') def get_context_data(self, **kwargs): """Add filter options.""" context = super().get_context_data(**kwargs) from core.models import Clinic context['specialty_choices'] = Clinic.Specialty.choices return context class PackageCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, SuccessMessageMixin, CreateView): """ Package creation view. Features: - Create service package - Set pricing and sessions - Define validity period """ model = Package form_class = PackageForm template_name = 'finance/package_form.html' success_message = _("Package created successfully!") success_url = reverse_lazy('finance:package_list') allowed_roles = [User.Role.ADMIN, User.Role.FINANCE] def get_context_data(self, **kwargs): """Add form title and service formset.""" context = super().get_context_data(**kwargs) context['form_title'] = _('Create New Package') context['submit_text'] = _('Create Package') # Add package service formset if self.request.POST: context['service_formset'] = PackageServiceFormSet( self.request.POST, instance=self.object ) else: context['service_formset'] = PackageServiceFormSet( instance=self.object ) return context def form_valid(self, form): """Set tenant and save formset.""" form.instance.tenant = self.request.user.tenant # Save package first self.object = form.save() # Get and validate formset formset = PackageServiceFormSet( self.request.POST, instance=self.object ) if formset.is_valid(): # Save package services formset.save() # Update total sessions self.object.total_sessions = self.object.calculate_total_sessions() self.object.save() messages.success(self.request, self.success_message) return redirect(self.get_success_url()) else: # If formset is invalid, delete the package and show errors self.object.delete() return self.form_invalid(form) class PackageUpdateView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, SuccessMessageMixin, UpdateView): """ Package update view. Features: - Update package details - Cannot update if in use """ model = Package form_class = PackageForm template_name = 'finance/package_form.html' success_message = _("Package updated successfully!") success_url = reverse_lazy('finance:package_list') allowed_roles = [User.Role.ADMIN, User.Role.FINANCE] def get_context_data(self, **kwargs): """Add form title and service formset.""" context = super().get_context_data(**kwargs) context['form_title'] = _('Update Package: %(name)s') % {'name': self.object.name_en} context['submit_text'] = _('Update Package') # Add package service formset if self.request.POST: context['service_formset'] = PackageServiceFormSet( self.request.POST, instance=self.object ) else: context['service_formset'] = PackageServiceFormSet( instance=self.object ) return context def form_valid(self, form): """Save package and formset.""" # Save package self.object = form.save() # Get and validate formset formset = PackageServiceFormSet( self.request.POST, instance=self.object ) if formset.is_valid(): # Save package services formset.save() # Update total sessions self.object.total_sessions = self.object.calculate_total_sessions() self.object.save() messages.success(self.request, self.success_message) return redirect(self.get_success_url()) else: # If formset is invalid, show errors return self.form_invalid(form) class PayerListView(LoginRequiredMixin, TenantFilterMixin, PaginationMixin, ListView): """ Payer list view. Features: - List all payers (insurance companies, etc.) - Filter by type - Show statistics """ model = Payer template_name = 'finance/payer_list.html' context_object_name = 'payers' paginate_by = 25 def get_queryset(self): """Get filtered queryset.""" queryset = super().get_queryset() # Apply search search_query = self.request.GET.get('search', '').strip() if search_query: queryset = queryset.filter( Q(name__icontains=search_query) | Q(patient__first_name_en__icontains=search_query) | Q(patient__last_name_en__icontains=search_query) | Q(patient__mrn__icontains=search_query) | Q(policy_number__icontains=search_query) ) # Apply filters payer_type = self.request.GET.get('payer_type') if payer_type: queryset = queryset.filter(payer_type=payer_type) is_active = self.request.GET.get('is_active') if is_active: queryset = queryset.filter(is_active=(is_active == 'true')) patient_id = self.request.GET.get('patient') if patient_id: queryset = queryset.filter(patient_id=patient_id) return queryset.select_related('patient').order_by('patient', 'name') def get_context_data(self, **kwargs): """Add statistics and filter options.""" context = super().get_context_data(**kwargs) context['payer_type_choices'] = Payer.PayerType.choices # Add current filters context['current_filters'] = { 'search': self.request.GET.get('search', ''), 'payer_type': self.request.GET.get('payer_type', ''), 'is_active': self.request.GET.get('is_active', ''), 'patient': self.request.GET.get('patient', ''), } # Add statistics queryset = self.get_queryset() context['stats'] = { 'total_payers': queryset.count(), 'active_payers': queryset.filter(is_active=True).count(), 'insurance_payers': queryset.filter(payer_type=Payer.PayerType.INSURANCE).count(), } return context class PayerDetailView(LoginRequiredMixin, TenantFilterMixin, DetailView): """ Payer detail view. Features: - Full payer details - Associated invoices - Coverage information - Payment history """ model = Payer template_name = 'finance/payer_detail.html' context_object_name = 'payer' def get_context_data(self, **kwargs): """Add related invoices and statistics.""" context = super().get_context_data(**kwargs) payer = self.object # Get related invoices invoices = payer.invoices.all().order_by('-issue_date')[:10] context['recent_invoices'] = invoices # Calculate statistics all_invoices = payer.invoices.all() context['invoice_stats'] = { 'total_invoices': all_invoices.count(), 'total_billed': all_invoices.aggregate(Sum('total'))['total__sum'] or 0, 'total_paid': sum(inv.amount_paid for inv in all_invoices), 'outstanding': sum(inv.amount_due for inv in all_invoices), } return context class PayerCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, SuccessMessageMixin, CreateView): """ Payer creation view. Features: - Create new payer/insurance record - Link to patient - Set coverage details """ model = Payer form_class = PayerForm template_name = 'finance/payer_form.html' success_message = _("Payer created successfully!") allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK, User.Role.FINANCE] def get_success_url(self): """Redirect to payer detail.""" return reverse_lazy('finance:payer_detail', kwargs={'pk': self.object.pk}) def get_context_data(self, **kwargs): """Add form title.""" context = super().get_context_data(**kwargs) context['form_title'] = _('Create New Payer') context['submit_text'] = _('Create Payer') # Get patient if provided in URL patient_id = self.request.GET.get('patient') if patient_id: try: patient = Patient.objects.get( pk=patient_id, tenant=self.request.user.tenant ) context['patient'] = patient except Patient.DoesNotExist: pass return context def get_form_kwargs(self): """Pass initial patient if provided.""" kwargs = super().get_form_kwargs() patient_id = self.request.GET.get('patient') if patient_id: kwargs['initial'] = kwargs.get('initial', {}) kwargs['initial']['patient'] = patient_id return kwargs def form_valid(self, form): """Set tenant.""" form.instance.tenant = self.request.user.tenant return super().form_valid(form) class PayerUpdateView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, SuccessMessageMixin, UpdateView): """ Payer update view. Features: - Update payer details - Modify coverage percentage - Activate/deactivate payer """ model = Payer form_class = PayerForm template_name = 'finance/payer_form.html' success_message = _("Payer updated successfully!") allowed_roles = [User.Role.ADMIN, User.Role.FINANCE] def get_success_url(self): """Redirect to payer detail.""" return reverse_lazy('finance:payer_detail', kwargs={'pk': self.object.pk}) def get_context_data(self, **kwargs): """Add form title.""" context = super().get_context_data(**kwargs) context['form_title'] = _('Update Payer: %(name)s') % {'name': self.object.name} context['submit_text'] = _('Update Payer') return context class InvoicePDFDownloadView(LoginRequiredMixin, TenantFilterMixin, View): """ Generate and download invoice PDF. Features: - Generate PDF with QR code - Bilingual (Arabic + English) - ZATCA-compliant format """ def get(self, request, pk): """Generate and return PDF.""" from finance.pdf_service import PDFService # Get invoice invoice = get_object_or_404( Invoice, pk=pk, tenant=request.user.tenant ) try: # Generate PDF pdf_content = PDFService.generate_invoice_pdf(invoice, include_xml=True) # Create response response = HttpResponse(pdf_content, content_type='application/pdf') # Check if this is for printing (inline) or downloading (attachment) if request.GET.get('print') == 'true': response['Content-Disposition'] = f'inline; filename="invoice_{invoice.invoice_number}.pdf"' else: response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.invoice_number}.pdf"' return response except Exception as e: messages.error(request, _("Error generating PDF: %(error)s") % {'error': str(e)}) return redirect('finance:invoice_detail', pk=pk) class PackagePurchaseListView(LoginRequiredMixin, TenantFilterMixin, ListView): """ Package purchase list view. Features: - List all package purchases - Filter by patient, status, package - Search functionality - Pagination """ model = PackagePurchase template_name = 'finance/package_purchase_list.html' context_object_name = 'package_purchases' paginate_by = 25 def get_queryset(self): """Get filtered queryset.""" queryset = super().get_queryset() # Apply search search_query = self.request.GET.get('search', '').strip() if search_query: queryset = queryset.filter( Q(patient__first_name_en__icontains=search_query) | Q(patient__last_name_en__icontains=search_query) | Q(patient__mrn__icontains=search_query) | Q(package__name_en__icontains=search_query) ) # Apply filters status = self.request.GET.get('status') if status: queryset = queryset.filter(status=status) patient_id = self.request.GET.get('patient') if patient_id: queryset = queryset.filter(patient_id=patient_id) package_id = self.request.GET.get('package') if package_id: queryset = queryset.filter(package_id=package_id) return queryset.select_related('patient', 'package', 'invoice').order_by('-purchase_date') def get_context_data(self, **kwargs): """Add filter options to context.""" context = super().get_context_data(**kwargs) context['status_choices'] = PackagePurchase.Status.choices return context class PackagePurchaseDetailView(LoginRequiredMixin, TenantFilterMixin, DetailView): """ Package purchase detail view. Features: - Package information (name, description, price) - Purchase details (purchase date, expiry date, status) - Session tracking (sessions used/remaining) - List of appointments booked using this package - Auto-schedule button (link to schedule remaining sessions) """ model = PackagePurchase template_name = 'finance/package_purchase_detail.html' context_object_name = 'package_purchase' def get_context_data(self, **kwargs): """Add package details and related appointments.""" context = super().get_context_data(**kwargs) package_purchase = self.object # Get related appointments that used this package from appointments.models import Appointment appointments = Appointment.objects.filter( package_purchase=package_purchase, tenant=self.request.user.tenant ).select_related('clinic', 'provider', 'provider__user').order_by('-scheduled_date', '-scheduled_time') context['appointments'] = appointments # Calculate progress percentage if package_purchase.total_sessions > 0: context['progress_percentage'] = int( (package_purchase.sessions_used / package_purchase.total_sessions) * 100 ) else: context['progress_percentage'] = 0 # Check if package can be scheduled context['can_schedule'] = ( package_purchase.status == PackagePurchase.Status.ACTIVE and package_purchase.sessions_remaining > 0 and not package_purchase.is_expired ) # Get package services for display context['package_services'] = package_purchase.package.packageservice_set.all().select_related('service') return context class FinancialReportView(LoginRequiredMixin, RolePermissionMixin, ListView): """ Financial reporting view. Features: - Revenue reports - Outstanding invoices - Payment trends - Payer analysis """ model = Invoice template_name = 'finance/financial_report.html' allowed_roles = [User.Role.ADMIN, User.Role.FINANCE] def get_queryset(self): """Get invoices for reporting period.""" queryset = Invoice.objects.filter(tenant=self.request.user.tenant) # Apply date range date_from = self.request.GET.get('date_from') if date_from: queryset = queryset.filter(issue_date__gte=date_from) date_to = self.request.GET.get('date_to') if date_to: queryset = queryset.filter(issue_date__lte=date_to) return queryset def get_context_data(self, **kwargs): """Add comprehensive financial statistics.""" import json from django.db.models.functions import TruncMonth context = super().get_context_data(**kwargs) queryset = self.get_queryset() # Get payments for the period payments = Payment.objects.filter( invoice__tenant=self.request.user.tenant, invoice__in=queryset, status=Payment.Status.COMPLETED ) # Main statistics for top cards total_invoiced = queryset.aggregate(Sum('total'))['total__sum'] or 0 total_collected = payments.aggregate(Sum('amount'))['amount__sum'] or 0 context['stats'] = { 'total_revenue': total_invoiced, 'collected': total_collected, 'outstanding': total_invoiced - total_collected, 'total_invoices': queryset.count(), } # Revenue by service (from invoice line items) from .models import InvoiceLineItem revenue_by_service = InvoiceLineItem.objects.filter( invoice__in=queryset ).values( 'service__name_en' ).annotate( count=Count('id'), revenue=Sum('total') ).order_by('-revenue')[:10] context['revenue_by_service'] = [ { 'service': item['service__name_en'] or 'N/A', 'count': item['count'], 'revenue': item['revenue'] or 0 } for item in revenue_by_service ] # Payment methods breakdown payment_methods_data = [] for method_code, method_name in Payment.PaymentMethod.choices: method_payments = payments.filter(method=method_code) amount = method_payments.aggregate(Sum('amount'))['amount__sum'] or 0 if amount > 0: payment_methods_data.append({ 'name': method_name, 'count': method_payments.count(), 'amount': amount }) context['payment_methods'] = payment_methods_data # Package sales - use package price from invoice line items package_sales_data = InvoiceLineItem.objects.filter( invoice__in=queryset, package__isnull=False # Only line items that reference a package ).values( 'package__name_en' ).annotate( count=Count('id'), revenue=Sum('total') # Use the line item total, not invoice total ).order_by('-revenue')[:10] context['package_sales'] = [ { 'name': item['package__name_en'] or 'N/A', 'count': item['count'], 'revenue': item['revenue'] or 0 } for item in package_sales_data ] # Monthly revenue trend monthly_data = queryset.annotate( month=TruncMonth('issue_date') ).values('month').annotate( revenue=Sum('total') ).order_by('month') monthly_labels = [item['month'].strftime('%b %Y') if item['month'] else 'N/A' for item in monthly_data] monthly_revenue = [float(item['revenue']) if item['revenue'] else 0 for item in monthly_data] context['monthly_labels'] = json.dumps(monthly_labels) context['monthly_revenue'] = json.dumps(monthly_revenue) return context