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

975 lines
28 KiB
Python

"""
Finance models for the Tenhal Multidisciplinary Healthcare Platform.
This module handles billing, invoicing, payments, and insurance/payer management.
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords
import hashlib
import base64
import json
from core.models import (
UUIDPrimaryKeyMixin,
TimeStampedMixin,
TenantOwnedMixin,
)
class Service(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Billable services offered by the clinic.
Each service has a base price and duration.
"""
code = models.CharField(
max_length=50,
verbose_name=_("Service Code")
)
name_en = models.CharField(
max_length=200,
verbose_name=_("Name (English)")
)
name_ar = models.CharField(
max_length=200,
blank=True,
verbose_name=_("Name (Arabic)")
)
clinic = models.ForeignKey(
'core.Clinic',
on_delete=models.CASCADE,
related_name='services',
verbose_name=_("Clinic")
)
base_price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Base Price")
)
duration_minutes = models.PositiveIntegerField(
default=30,
verbose_name=_("Duration (minutes)")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Is Active")
)
description = models.TextField(
blank=True,
verbose_name=_("Description")
)
class Meta:
verbose_name = _("Service")
verbose_name_plural = _("Services")
ordering = ['clinic', 'name_en']
unique_together = [['tenant', 'code']]
def __str__(self):
return f"{self.code} - {self.name_en}"
class Package(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Service packages (e.g., 10 SLP sessions bundle).
Offers discounted pricing for multiple sessions.
"""
name_en = models.CharField(
max_length=200,
verbose_name=_("Name (English)")
)
name_ar = models.CharField(
max_length=200,
blank=True,
verbose_name=_("Name (Arabic)")
)
services = models.ManyToManyField(
Service,
through='PackageService',
related_name='packages',
verbose_name=_("Services")
)
total_sessions = models.PositiveIntegerField(
default=0,
verbose_name=_("Total Sessions"),
help_text=_("Auto-calculated from service sessions")
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Package Price")
)
validity_days = models.PositiveIntegerField(
default=90,
help_text=_("Number of days the package is valid after purchase"),
verbose_name=_("Validity (days)")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Is Active")
)
description = models.TextField(
blank=True,
verbose_name=_("Description")
)
class Meta:
verbose_name = _("Package")
verbose_name_plural = _("Packages")
ordering = ['name_en']
def __str__(self):
return f"{self.name_en} ({self.total_sessions} sessions)"
def calculate_total_sessions(self):
"""Calculate total sessions from all package services."""
return sum(ps.sessions for ps in self.packageservice_set.all())
def save(self, *args, **kwargs):
"""Override save to auto-calculate total sessions."""
super().save(*args, **kwargs)
# Update total_sessions after save (when through relationships exist)
if self.pk:
self.total_sessions = self.calculate_total_sessions()
if self.total_sessions != self._state.fields_cache.get('total_sessions', 0):
Package.objects.filter(pk=self.pk).update(total_sessions=self.total_sessions)
class PackageService(UUIDPrimaryKeyMixin):
"""
Intermediate model linking packages to services with session counts.
Allows specifying how many sessions of each service are included in a package.
"""
package = models.ForeignKey(
Package,
on_delete=models.CASCADE,
verbose_name=_("Package")
)
service = models.ForeignKey(
Service,
on_delete=models.CASCADE,
verbose_name=_("Service")
)
sessions = models.PositiveIntegerField(
default=1,
verbose_name=_("Number of Sessions"),
help_text=_("Number of sessions for this service in the package")
)
session_order = models.PositiveIntegerField(
default=1,
verbose_name=_("Session Order"),
help_text=_("Order in which this service should be delivered (for clinical sequence)")
)
class Meta:
verbose_name = _("Package Service")
verbose_name_plural = _("Package Services")
unique_together = [['package', 'service']]
ordering = ['package', 'service']
def __str__(self):
return f"{self.package.name_en} - {self.service.name_en} ({self.sessions} sessions)"
class Payer(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Insurance companies or other payers.
Tracks coverage details for patients.
"""
class PayerType(models.TextChoices):
SELF = 'SELF', _('Self Pay')
INSURANCE = 'INSURANCE', _('Insurance')
GOVERNMENT = 'GOVERNMENT', _('Government')
CORPORATE = 'CORPORATE', _('Corporate')
patient = models.ForeignKey(
'core.Patient',
on_delete=models.CASCADE,
related_name='payers',
verbose_name=_("Patient")
)
name = models.CharField(
max_length=200,
verbose_name=_("Payer Name")
)
payer_type = models.CharField(
max_length=20,
choices=PayerType.choices,
default=PayerType.SELF,
verbose_name=_("Payer Type")
)
policy_number = models.CharField(
max_length=100,
blank=True,
verbose_name=_("Policy Number")
)
coverage_percentage = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0,
help_text=_("Percentage of costs covered (0-100)"),
verbose_name=_("Coverage %")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Is Active")
)
notes = models.TextField(
blank=True,
verbose_name=_("Notes")
)
class Meta:
verbose_name = _("Payer")
verbose_name_plural = _("Payers")
ordering = ['patient', 'name']
def __str__(self):
return f"{self.name} - {self.patient}"
class Invoice(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Patient invoices for services rendered.
Tracks billing and payment status.
ZATCA E-Invoice compliant.
"""
class Status(models.TextChoices):
DRAFT = 'DRAFT', _('Draft')
ISSUED = 'ISSUED', _('Issued')
PAID = 'PAID', _('Paid')
PARTIALLY_PAID = 'PARTIALLY_PAID', _('Partially Paid')
CANCELLED = 'CANCELLED', _('Cancelled')
OVERDUE = 'OVERDUE', _('Overdue')
class InvoiceType(models.TextChoices):
STANDARD = 'STANDARD', _('Standard Tax Invoice (B2B)')
SIMPLIFIED = 'SIMPLIFIED', _('Simplified Tax Invoice (B2C)')
STANDARD_DEBIT = 'STANDARD_DEBIT', _('Standard Debit Note')
STANDARD_CREDIT = 'STANDARD_CREDIT', _('Standard Credit Note')
SIMPLIFIED_DEBIT = 'SIMPLIFIED_DEBIT', _('Simplified Debit Note')
SIMPLIFIED_CREDIT = 'SIMPLIFIED_CREDIT', _('Simplified Credit Note')
# Invoice Identification
invoice_number = models.CharField(
max_length=20,
unique=True,
editable=False,
verbose_name=_("Invoice Number"),
help_text=_("Unique invoice reference number (IRN)")
)
# ZATCA Required: Invoice Counter Value (ICV)
invoice_counter = models.PositiveIntegerField(
editable=False,
db_index=True,
default=1,
verbose_name=_("Invoice Counter"),
help_text=_("Sequential counter per EGS unit, cannot be reset")
)
# ZATCA Required: Invoice Type
invoice_type = models.CharField(
max_length=20,
choices=InvoiceType.choices,
default=InvoiceType.SIMPLIFIED,
verbose_name=_("Invoice Type"),
help_text=_("Standard (B2B) requires clearance, Simplified (B2C) requires reporting")
)
# Core Relationships
patient = models.ForeignKey(
'core.Patient',
on_delete=models.CASCADE,
related_name='invoices',
verbose_name=_("Patient")
)
appointment = models.ForeignKey(
'appointments.Appointment',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='invoices',
verbose_name=_("Appointment")
)
payer = models.ForeignKey(
Payer,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='invoices',
verbose_name=_("Payer")
)
# Credit/Debit Note Reference
billing_reference_id = models.CharField(
max_length=50,
blank=True,
verbose_name=_("Billing Reference ID"),
help_text=_("Reference to original invoice for credit/debit notes")
)
billing_reference_issue_date = models.DateField(
null=True,
blank=True,
verbose_name=_("Billing Reference Issue Date")
)
# Dates
issue_date = models.DateField(
verbose_name=_("Issue Date")
)
issue_time = models.TimeField(
auto_now_add=True,
null=True,
blank=True,
verbose_name=_("Issue Time"),
help_text=_("Time when invoice was generated")
)
due_date = models.DateField(
verbose_name=_("Due Date")
)
# Amounts
subtotal = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Subtotal")
)
tax = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Tax (VAT)")
)
discount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Discount")
)
total = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Total")
)
# Status
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT,
verbose_name=_("Status")
)
# ZATCA Required: Hash Chain
invoice_hash = models.CharField(
max_length=64,
blank=True,
editable=False,
verbose_name=_("Invoice Hash"),
help_text=_("SHA-256 hash of this invoice")
)
previous_invoice_hash = models.CharField(
max_length=64,
blank=True,
verbose_name=_("Previous Invoice Hash"),
help_text=_("SHA-256 hash of previous invoice (PIH)")
)
# ZATCA Required: QR Code
qr_code = models.TextField(
blank=True,
verbose_name=_("QR Code"),
help_text=_("Base64 encoded TLV QR code")
)
# ZATCA Phase 2: Cryptographic Stamp (for Simplified Invoices)
cryptographic_stamp = models.TextField(
blank=True,
verbose_name=_("Cryptographic Stamp"),
help_text=_("ECDSA signature for simplified invoices")
)
# ZATCA Phase 2: Clearance/Reporting Status
zatca_status = models.CharField(
max_length=20,
blank=True,
verbose_name=_("ZATCA Status"),
help_text=_("Clearance/Reporting status from ZATCA")
)
zatca_submission_date = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("ZATCA Submission Date")
)
zatca_response = models.JSONField(
null=True,
blank=True,
verbose_name=_("ZATCA Response"),
help_text=_("Response from ZATCA clearance/reporting API")
)
# XML Storage
xml_content = models.TextField(
blank=True,
verbose_name=_("XML Content"),
help_text=_("UBL 2.1 XML invoice content")
)
# Additional Information
notes = models.TextField(
blank=True,
verbose_name=_("Notes")
)
history = HistoricalRecords()
class Meta:
verbose_name = _("Invoice")
verbose_name_plural = _("Invoices")
ordering = ['-issue_date', '-invoice_counter']
indexes = [
models.Index(fields=['invoice_number']),
models.Index(fields=['invoice_counter']),
models.Index(fields=['patient', 'issue_date']),
models.Index(fields=['status', 'issue_date']),
models.Index(fields=['tenant', 'issue_date']),
models.Index(fields=['invoice_type', 'status']),
]
def __str__(self):
return f"Invoice #{self.invoice_number} - {self.patient}"
@property
def amount_paid(self):
"""Calculate total amount paid."""
return sum(
payment.amount
for payment in self.payments.filter(status=Payment.Status.COMPLETED)
)
@property
def amount_due(self):
"""Calculate remaining amount due."""
return self.total - self.amount_paid
@property
def is_fully_paid(self):
"""Check if invoice is fully paid."""
return self.amount_due <= 0
@property
def is_saudi_patient(self):
"""
Check if patient is Saudi based on national ID.
Saudi national IDs start with '1', non-Saudi residents start with '2'.
"""
if self.patient and self.patient.national_id:
return self.patient.national_id.startswith('1')
return False
@property
def should_apply_vat(self):
"""
Determine if VAT should be applied to this invoice.
VAT (15%) is only applied to non-Saudi patients.
"""
return not self.is_saudi_patient
@property
def is_credit_or_debit_note(self):
"""Check if this is a credit or debit note."""
return self.invoice_type in [
self.InvoiceType.STANDARD_CREDIT,
self.InvoiceType.STANDARD_DEBIT,
self.InvoiceType.SIMPLIFIED_CREDIT,
self.InvoiceType.SIMPLIFIED_DEBIT,
]
def generate_invoice_hash(self):
"""
Generate SHA-256 hash of invoice data.
This is used for the hash chain (PIH).
"""
# Prepare invoice data for hashing
hash_data = {
'invoice_number': self.invoice_number,
'invoice_counter': self.invoice_counter,
'issue_date': str(self.issue_date),
'issue_time': str(self.issue_time),
'total': str(self.total),
'tax': str(self.tax),
'patient_id': str(self.patient.id) if self.patient else '',
}
# Convert to JSON string and encode
json_str = json.dumps(hash_data, sort_keys=True)
hash_bytes = hashlib.sha256(json_str.encode('utf-8')).digest()
# Return hex representation
return hash_bytes.hex()
def generate_qr_code(self):
"""
Generate ZATCA-compliant QR code in TLV (Tag-Length-Value) format.
Encoded in Base64.
Phase 1 (Generation Phase) - 5 fields:
1. Seller's Name
2. VAT Registration Number
3. Timestamp (date and time)
4. Invoice total (with VAT)
5. VAT total
Phase 2 (Integration Phase) - Additional 4 fields:
6. Hash of XML invoice
7. ECDSA signature
8. ECDSA public key
9. ECDSA signature of cryptographic stamp's public key
"""
def encode_tlv(tag, value):
"""Encode a single TLV entry."""
value_bytes = str(value).encode('utf-8')
length = len(value_bytes)
return bytes([tag, length]) + value_bytes
# Get seller information from tenant
seller_name = self.tenant.name if self.tenant else "Agdar Centre"
vat_number = self.tenant.vat_number if hasattr(self.tenant, 'vat_number') else "300000000000003"
# Tag 1: Seller's Name
tlv_data = encode_tlv(1, seller_name)
# Tag 2: VAT Registration Number
tlv_data += encode_tlv(2, vat_number)
# Tag 3: Timestamp (ISO 8601 format)
timestamp = f"{self.issue_date}T{self.issue_time}Z"
tlv_data += encode_tlv(3, timestamp)
# Tag 4: Invoice total (with VAT)
tlv_data += encode_tlv(4, f"{self.total:.2f}")
# Tag 5: VAT total
tlv_data += encode_tlv(5, f"{self.tax:.2f}")
# Phase 2 fields (if available)
if self.invoice_hash:
# Tag 6: Hash of XML invoice (Base64 encoded)
hash_base64 = base64.b64encode(bytes.fromhex(self.invoice_hash)).decode('utf-8')
tlv_data += encode_tlv(6, hash_base64)
if self.cryptographic_stamp:
# Tag 7: ECDSA signature
tlv_data += encode_tlv(7, self.cryptographic_stamp)
# Encode entire TLV data in Base64
qr_code_base64 = base64.b64encode(tlv_data).decode('utf-8')
return qr_code_base64
def save(self, *args, **kwargs):
"""Override save to generate hash and QR code."""
# Generate invoice hash if not set
if not self.invoice_hash:
self.invoice_hash = self.generate_invoice_hash()
# Generate QR code
self.qr_code = self.generate_qr_code()
super().save(*args, **kwargs)
class InvoiceLineItem(UUIDPrimaryKeyMixin):
"""
Individual line items on an invoice.
Can reference a service or package.
"""
invoice = models.ForeignKey(
Invoice,
on_delete=models.CASCADE,
related_name='line_items',
verbose_name=_("Invoice")
)
service = models.ForeignKey(
Service,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='invoice_line_items',
verbose_name=_("Service")
)
package = models.ForeignKey(
Package,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='invoice_line_items',
verbose_name=_("Package")
)
description = models.CharField(
max_length=500,
blank=True,
null=True,
verbose_name=_("Description")
)
quantity = models.PositiveIntegerField(
default=1,
verbose_name=_("Quantity")
)
unit_price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Unit Price")
)
total = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Total")
)
class Meta:
verbose_name = _("Invoice Line Item")
verbose_name_plural = _("Invoice Line Items")
ordering = ['invoice', 'id']
def __str__(self):
return f"{self.description} x {self.quantity}"
def save(self, *args, **kwargs):
# Auto-calculate total
self.total = self.unit_price * self.quantity
super().save(*args, **kwargs)
class Payment(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Payment records for invoices.
Tracks payment method, amount, and status.
"""
class PaymentMethod(models.TextChoices):
CASH = 'CASH', _('Cash')
CARD = 'CARD', _('Credit/Debit Card')
BANK_TRANSFER = 'BANK_TRANSFER', _('Bank Transfer')
INSURANCE = 'INSURANCE', _('Insurance')
CHECK = 'CHECK', _('Check')
OTHER = 'OTHER', _('Other')
class Status(models.TextChoices):
PENDING = 'PENDING', _('Pending')
COMPLETED = 'COMPLETED', _('Completed')
FAILED = 'FAILED', _('Failed')
REFUNDED = 'REFUNDED', _('Refunded')
invoice = models.ForeignKey(
Invoice,
on_delete=models.CASCADE,
related_name='payments',
verbose_name=_("Invoice")
)
payment_date = models.DateTimeField(
verbose_name=_("Payment Date")
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
verbose_name=_("Amount")
)
method = models.CharField(
max_length=20,
choices=PaymentMethod.choices,
verbose_name=_("Payment Method")
)
transaction_id = models.CharField(
max_length=100,
blank=True,
verbose_name=_("Transaction ID")
)
reference = models.CharField(
max_length=200,
blank=True,
verbose_name=_("Reference")
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
verbose_name=_("Status")
)
processed_by = models.ForeignKey(
'core.User',
on_delete=models.SET_NULL,
null=True,
related_name='processed_payments',
verbose_name=_("Processed By")
)
notes = models.TextField(
blank=True,
verbose_name=_("Notes")
)
is_commission_free = models.BooleanField(
default=False,
verbose_name=_("Commission Free"),
help_text=_("Mark this payment as commission-free (no commission charged)")
)
class Meta:
verbose_name = _("Payment")
verbose_name_plural = _("Payments")
ordering = ['-payment_date']
indexes = [
models.Index(fields=['invoice', 'payment_date']),
models.Index(fields=['status', 'payment_date']),
models.Index(fields=['tenant', 'payment_date']),
models.Index(fields=['is_commission_free']),
]
def __str__(self):
return f"Payment {self.amount} for {self.invoice}"
class CSID(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Cryptographic Stamp Identifier storage.
Stores CSIDs obtained from ZATCA for each EGS unit.
"""
class Status(models.TextChoices):
ACTIVE = 'ACTIVE', _('Active')
EXPIRED = 'EXPIRED', _('Expired')
REVOKED = 'REVOKED', _('Revoked')
PENDING = 'PENDING', _('Pending')
class CSIDType(models.TextChoices):
COMPLIANCE = 'COMPLIANCE', _('Compliance CSID')
PRODUCTION = 'PRODUCTION', _('Production CSID')
# CSID Information
csid_type = models.CharField(
max_length=20,
choices=CSIDType.choices,
default=CSIDType.PRODUCTION,
verbose_name=_("CSID Type")
)
# Certificate Details
certificate = models.TextField(
verbose_name=_("Certificate"),
help_text=_("Base64 encoded certificate")
)
secret = models.CharField(
max_length=500,
verbose_name=_("Secret"),
help_text=_("CSID secret (encrypted)")
)
request_id = models.CharField(
max_length=100,
blank=True,
verbose_name=_("Request ID"),
help_text=_("Request ID from compliance CSID")
)
# EGS Unit Information
egs_serial_number = models.CharField(
max_length=200,
verbose_name=_("EGS Serial Number"),
help_text=_("Format: 1-Manufacturer|2-Model|3-SerialNumber")
)
common_name = models.CharField(
max_length=200,
verbose_name=_("Common Name"),
help_text=_("Name or Asset Tracking Number for the Solution Unit")
)
organization_unit = models.CharField(
max_length=200,
blank=True,
verbose_name=_("Organization Unit"),
help_text=_("Branch name or TIN for VAT groups")
)
# Validity
issue_date = models.DateTimeField(
verbose_name=_("Issue Date")
)
expiry_date = models.DateTimeField(
verbose_name=_("Expiry Date")
)
# Status
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
verbose_name=_("Status")
)
# Revocation
revocation_date = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Revocation Date")
)
revocation_reason = models.TextField(
blank=True,
verbose_name=_("Revocation Reason")
)
# Usage Statistics
invoices_signed = models.PositiveIntegerField(
default=0,
verbose_name=_("Invoices Signed"),
help_text=_("Number of invoices signed with this CSID")
)
last_used = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Last Used")
)
class Meta:
verbose_name = _("CSID")
verbose_name_plural = _("CSIDs")
ordering = ['-issue_date']
indexes = [
models.Index(fields=['tenant', 'status']),
models.Index(fields=['expiry_date', 'status']),
models.Index(fields=['egs_serial_number']),
]
def __str__(self):
return f"CSID {self.common_name} - {self.get_status_display()}"
@property
def is_valid(self) -> bool:
"""Check if CSID is currently valid."""
if self.status != self.Status.ACTIVE:
return False
from django.utils import timezone
now = timezone.now()
return self.issue_date <= now <= self.expiry_date
@property
def days_until_expiry(self) -> int:
"""Calculate days until CSID expires."""
if self.expiry_date:
from django.utils import timezone
delta = self.expiry_date - timezone.now()
return max(0, delta.days)
return 0
@property
def needs_renewal(self) -> bool:
"""Check if CSID needs renewal (within 30 days of expiry)."""
return self.days_until_expiry <= 30 and self.status == self.Status.ACTIVE
def revoke(self, reason: str = ""):
"""Revoke this CSID."""
from django.utils import timezone
self.status = self.Status.REVOKED
self.revocation_date = timezone.now()
self.revocation_reason = reason
self.save()
def increment_usage(self):
"""Increment usage counter when invoice is signed."""
from django.utils import timezone
self.invoices_signed += 1
self.last_used = timezone.now()
self.save(update_fields=['invoices_signed', 'last_used'])
class PackagePurchase(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Tracks purchased packages and their usage.
"""
class Status(models.TextChoices):
ACTIVE = 'ACTIVE', _('Active')
EXPIRED = 'EXPIRED', _('Expired')
COMPLETED = 'COMPLETED', _('Completed')
CANCELLED = 'CANCELLED', _('Cancelled')
patient = models.ForeignKey(
'core.Patient',
on_delete=models.CASCADE,
related_name='package_purchases',
verbose_name=_("Patient")
)
package = models.ForeignKey(
Package,
on_delete=models.CASCADE,
related_name='purchases',
verbose_name=_("Package")
)
invoice = models.ForeignKey(
Invoice,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='package_purchases',
verbose_name=_("Invoice")
)
purchase_date = models.DateField(
verbose_name=_("Purchase Date")
)
expiry_date = models.DateField(
verbose_name=_("Expiry Date")
)
total_sessions = models.PositiveIntegerField(
verbose_name=_("Total Sessions")
)
sessions_used = models.PositiveIntegerField(
default=0,
verbose_name=_("Sessions Used")
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
verbose_name=_("Status")
)
class Meta:
verbose_name = _("Package Purchase")
verbose_name_plural = _("Package Purchases")
ordering = ['-purchase_date']
indexes = [
models.Index(fields=['patient', 'status']),
models.Index(fields=['expiry_date', 'status']),
]
def __str__(self):
return f"{self.package.name_en} - {self.patient}"
@property
def sessions_remaining(self):
"""Calculate remaining sessions."""
return self.total_sessions - self.sessions_used
@property
def is_expired(self):
"""Check if package has expired."""
from datetime import date
return date.today() > self.expiry_date
@property
def is_completed(self):
"""Check if all sessions have been used."""
return self.sessions_used >= self.total_sessions