975 lines
28 KiB
Python
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
|