218 lines
8.7 KiB
Python
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,
|
|
}
|