39710-vm/core/models.py
2026-04-17 04:26:07 +00:00

218 lines
8.7 KiB
Python

from decimal import Decimal, ROUND_HALF_UP
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
class BusinessProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='business_profile')
business_name = models.CharField(max_length=120, blank=True)
logo = models.FileField(upload_to='business_logos/', blank=True, null=True)
opening_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
opening_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
current_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
current_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
mobile_token_version = models.PositiveIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['user__username']
def __str__(self):
return self.business_name or f"{self.user.username}'s MoMo Business"
@property
def owner_label(self):
return self.user.get_full_name() or self.user.username
@property
def total_cash(self):
return (self.current_ecash or Decimal('0.00')) + (self.current_physical_cash or Decimal('0.00'))
def sync_current_to_opening(self):
self.current_ecash = self.opening_ecash
self.current_physical_cash = self.opening_physical_cash
class Transaction(models.Model):
CASH_OUT = 'cash_out'
CASH_IN = 'cash_in'
SENDING = 'sending'
AIRTIME = 'airtime'
TRANSFER = 'transfer'
DEBT = 'debt'
EXPENDITURE = 'expenditure'
CREDIT = 'credit'
TYPE_CHOICES = [
(CASH_OUT, 'Cash-out'),
(CASH_IN, 'Cash-In'),
(SENDING, 'Sending'),
(AIRTIME, 'Airtime'),
(TRANSFER, 'Transfer'),
(DEBT, 'Debt'),
(EXPENDITURE, 'Expenditure'),
(CREDIT, 'Credit'),
]
business = models.ForeignKey(BusinessProfile, on_delete=models.CASCADE, related_name='transactions')
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='momo_transactions')
client_name = models.CharField(max_length=120)
amount = models.DecimalField(max_digits=12, decimal_places=2)
transaction_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
service_charge = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
notes = models.CharField(max_length=255, blank=True)
ecash_before = models.DecimalField(max_digits=12, decimal_places=2)
ecash_after = models.DecimalField(max_digits=12, decimal_places=2)
physical_before = models.DecimalField(max_digits=12, decimal_places=2)
physical_after = models.DecimalField(max_digits=12, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at', '-id']
def __str__(self):
return f"{self.get_transaction_type_display()} · {self.client_name} · {self.amount}"
@staticmethod
def _round(value: Decimal) -> Decimal:
return value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
@classmethod
def calculate_effect(cls, transaction_type: str, amount: Decimal) -> tuple[Decimal, Decimal, Decimal]:
amount = cls._round(amount)
fee = Decimal('0.00')
ecash_delta = Decimal('0.00')
physical_delta = Decimal('0.00')
if transaction_type == cls.CASH_IN:
ecash_delta = -amount
physical_delta = amount
elif transaction_type == cls.CASH_OUT:
ecash_delta = amount
physical_delta = -amount
elif transaction_type in {cls.AIRTIME, cls.TRANSFER}:
ecash_delta = -amount
physical_delta = amount
elif transaction_type == cls.SENDING:
fee = cls._round(amount * Decimal('0.01'))
ecash_delta = -amount
physical_delta = amount + fee
elif transaction_type in {cls.DEBT, cls.EXPENDITURE}:
physical_delta = -amount
elif transaction_type == cls.CREDIT:
physical_delta = amount
else:
raise ValidationError('Unsupported transaction type.')
return cls._round(ecash_delta), cls._round(physical_delta), cls._round(fee)
@classmethod
@transaction.atomic
def create_logged_transaction(
cls,
*,
business: BusinessProfile,
user: User,
client_name: str,
amount: Decimal,
transaction_type: str,
notes: str = '',
):
ecash_delta, physical_delta, fee = cls.calculate_effect(transaction_type, amount)
ecash_before = cls._round(business.current_ecash)
physical_before = cls._round(business.current_physical_cash)
ecash_after = cls._round(ecash_before + ecash_delta)
physical_after = cls._round(physical_before + physical_delta)
if ecash_after < 0:
raise ValidationError('This transaction would make e-cash go below zero.')
if physical_after < 0:
raise ValidationError('This transaction would make physical cash go below zero.')
entry = cls.objects.create(
business=business,
created_by=user,
client_name=client_name,
amount=cls._round(amount),
transaction_type=transaction_type,
service_charge=fee,
notes=notes,
ecash_before=ecash_before,
ecash_after=ecash_after,
physical_before=physical_before,
physical_after=physical_after,
)
business.current_ecash = ecash_after
business.current_physical_cash = physical_after
business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at'])
return entry
@classmethod
def rebalance_business_ledger(cls, business: BusinessProfile):
business = BusinessProfile.objects.select_for_update().get(pk=business.pk)
ecash_balance = cls._round(business.opening_ecash)
physical_balance = cls._round(business.opening_physical_cash)
entries = list(
cls.objects.select_for_update()
.filter(business=business)
.order_by('created_at', 'id')
)
for entry in entries:
ecash_delta, physical_delta, fee = cls.calculate_effect(entry.transaction_type, entry.amount)
ecash_before = ecash_balance
physical_before = physical_balance
ecash_after = cls._round(ecash_before + ecash_delta)
physical_after = cls._round(physical_before + physical_delta)
if ecash_after < 0:
raise ValidationError('This change would make e-cash go below zero in your transaction history.')
if physical_after < 0:
raise ValidationError('This change would make physical cash go below zero in your transaction history.')
cls.objects.filter(pk=entry.pk).update(
service_charge=fee,
ecash_before=ecash_before,
ecash_after=ecash_after,
physical_before=physical_before,
physical_after=physical_after,
)
ecash_balance = ecash_after
physical_balance = physical_after
business.current_ecash = ecash_balance
business.current_physical_cash = physical_balance
business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at'])
return business
@transaction.atomic
def update_logged_transaction(self, *, client_name: str, amount: Decimal, transaction_type: str, notes: str = ''):
business = BusinessProfile.objects.select_for_update().get(pk=self.business_id)
self.client_name = client_name
self.amount = self.__class__._round(amount)
self.transaction_type = transaction_type
self.notes = notes
self.save(update_fields=['client_name', 'amount', 'transaction_type', 'notes'])
business = self.__class__.rebalance_business_ledger(business)
refreshed_entry = self.__class__.objects.select_related('created_by').get(pk=self.pk)
return {
'transaction': refreshed_entry,
'business': business,
}
@transaction.atomic
def delete_logged_transaction(self):
business = BusinessProfile.objects.select_for_update().get(pk=self.business_id)
transaction_id = self.pk
self.delete()
business = self.__class__.rebalance_business_ledger(business)
return {
'transaction_id': transaction_id,
'business': business,
}