""" 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