1426 lines
50 KiB
Python
1426 lines
50 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_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
|