agdar/finance/views.py
Marwan Alwali edb53e4264 update
2025-11-02 23:20:56 +03:00

1053 lines
36 KiB
Python

"""
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_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:
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')
# 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
# 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, and save formset."""
from finance.csid_manager import InvoiceCounterManager
from django.db import transaction
# 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
# 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 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'))
return queryset.order_by('name')
def get_context_data(self, **kwargs):
"""Add statistics."""
context = super().get_context_data(**kwargs)
context['payer_type_choices'] = Payer.PayerType.choices
# Add statistics
queryset = self.get_queryset()
context['stats'] = {
'total_payers': queryset.count(),
'active_payers': queryset.filter(is_active=True).count(),
}
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 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