wallet with category

This commit is contained in:
Sayyid Hamid Mahdavi 2025-08-17 21:15:54 +03:30
parent 59ccfbfd8f
commit eefc6dd4c1
4 changed files with 445 additions and 19 deletions

View file

@ -1,22 +1,48 @@
import datetime import datetime
import uuid import uuid
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db import transaction from django.db import transaction
from django.db.models import F from django.db.models import F
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from oauth2_provider.models import get_application_model
from rest_framework.exceptions import APIException, ValidationError from rest_framework.exceptions import APIException, ValidationError
from django.conf import settings from django.conf import settings
from oauth2_provider import settings as oauth2_settings from oauth2_provider import settings as oauth2_settings
from gooyal_accounts_client.models import PublicUser from gooyal_accounts_client.models import PublicUser
from utils.accounts_client import get_user_details, get_application_details from utils.accounts_client import get_user_details, get_application_details
from utils.models import BaseModel
from .constans import StateChoices, TypeChoices from .constans import StateChoices, TypeChoices
from apps.users.models import User from apps.users.models import User
from ..gooyal_oauth2.models import Application from ..gooyal_oauth2.models import Application
class WalletQuerySet(models.QuerySet):
pass
class Wallet(BaseModel):
owner_uuid = models.UUIDField(db_index=True, null=False)
category = models.ForeignKey('Category', on_delete=models.PROTECT, null=True)
owner_type = models.IntegerField(choices=TypeChoices.choices, db_index=True)
balance = models.BigIntegerField(_('balance'), default=0)
objects = WalletQuerySet.as_manager()
class CategoryQuerySet(models.QuerySet):
pass
class Category(BaseModel):
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, null=True)
label = models.CharField(_('label'), max_length=255)
objects = CategoryQuerySet.as_manager()
# TODO: move DRF functionalities to view or serializers # TODO: move DRF functionalities to view or serializers
# class TransactionManager(models.Manager): # class TransactionManager(models.Manager):
# def create_transaction( # def create_transaction(
@ -47,36 +73,43 @@ from ..gooyal_oauth2.models import Application
# return tr # return tr
class Transaction(models.Model): class TransactionQuerySet(models.QuerySet):
uuid = models.UUIDField(primary_key=True, editable=True, default=uuid.uuid4, unique=True, pass
db_index=True) # reserve_code
class Transaction(BaseModel):
amount = models.PositiveBigIntegerField(_('amount'), blank=False, validators=[MinValueValidator(1)]) amount = models.PositiveBigIntegerField(_('amount'), blank=False, validators=[MinValueValidator(1)])
delay = models.PositiveIntegerField(default=0, blank=True) # number of days for delayed payments delay = models.PositiveIntegerField(default=0, blank=True) # number of days for delayed payments
payer_id = models.UUIDField(blank=True, null=True) # payer_id = models.UUIDField(blank=True, null=True)
payer_type = models.IntegerField(choices=TypeChoices.choices, blank=True, null=True) # payer_type = models.IntegerField(choices=TypeChoices.choices, blank=True, null=True)
payee_id = models.UUIDField() # payee_id = models.UUIDField()
payee_type = models.IntegerField(choices=TypeChoices.choices, blank=True, null=True) # payee_type = models.IntegerField(choices=TypeChoices.choices, blank=True, null=True)
state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED) state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED)
created_at = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True, null=True, db_index=True)
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT,
related_name='transactions', null=True) related_name='transactions', null=True)
payer_wallet = models.ForeignKey(Wallet, on_delete=models.PROTECT, null=True,
related_name='payer_wallet_transactions',
db_index=True)
payee_wallet = models.ForeignKey(Wallet, on_delete=models.PROTECT, null=True,
related_name='payee_wallet_transactions',
db_index=True)
details = models.JSONField(null=True, blank=True, default=dict) details = models.JSONField(null=True, blank=True, default=dict)
# detail = {'payer_name', 'payee_name', 'description', 'application_name', 'payer_avatar_url', 'payee_avatar_url', # detail = {'payer_name', 'payee_name', 'description', 'application_name', 'payer_avatar_url', 'payee_avatar_url',
# 'reference_id', 'application_details_url'} # 'reference_id', 'application_details_url'}
# objects = TransactionManager() objects = TransactionQuerySet.as_manager()
class Meta: class Meta:
ordering = ['-created_at'] ordering = ['-created_at']
# unique_together = ('application', 'detail_id',) # unique_together = ('application', 'detail_id',)
def withdraw_from_payer_balance(self): def withdraw_from_payer_balance(self):
payer = self.get_payer() payer = self.get_payer()
payer_is_allowed_negative_balance = self.payer_type==TypeChoices.APPLICATION.value and str(self.payer_id) in settings.ALLOWED_NEGATIVE_BALANCE_APPLICATIONS payer_is_allowed_negative_balance = self.payer_type == TypeChoices.APPLICATION.value and str(
if self.amount > payer.balance and not payer_is_allowed_negative_balance : self.payer_id) in settings.ALLOWED_NEGATIVE_BALANCE_APPLICATIONS
if self.amount > payer.balance and not payer_is_allowed_negative_balance:
raise APIException('not enough balance') raise APIException('not enough balance')
payer.balance = F('balance') - self.amount payer.balance = F('balance') - self.amount
payer.save(update_fields=['balance']) payer.save(update_fields=['balance'])
@ -227,7 +260,7 @@ class Transaction(models.Model):
else: else:
payer_details = get_application_details(self.payer_id) payer_details = get_application_details(self.payer_id)
self.details['payer_name'] = payer_details.name self.details['payer_name'] = payer_details.name
#TODO: get avatar from details # TODO: get avatar from details
is_updated = True is_updated = True
print("payer_details:", payer_details) print("payer_details:", payer_details)
print("is_updated:", is_updated) print("is_updated:", is_updated)
@ -241,7 +274,7 @@ class Transaction(models.Model):
else: else:
payee_details = get_application_details(self.payee_id) payee_details = get_application_details(self.payee_id)
self.details['payee_name'] = payee_details.name self.details['payee_name'] = payee_details.name
#TODO: get avatar from details # TODO: get avatar from details
is_updated = True is_updated = True
print("payee_details:", payee_details) print("payee_details:", payee_details)
@ -264,9 +297,197 @@ class Transaction(models.Model):
print('update details') print('update details')
self.save() self.save()
def __str__(self): def __str__(self):
try: try:
return f"{self.get_payer()} -> {self.get_payee()} ({self.uuid}): {self.amount} - {self.get_state_display()}" return f"{self.get_payer()} -> {self.get_payee()} ({self.uuid}): {self.amount} - {self.get_state_display()}"
except: except:
return str(self.uuid) return str(self.uuid)
# def withdraw_from_payer_balance(self):
# payer = self.get_payer()
# payer_is_allowed_negative_balance = self.payer_type==TypeChoices.APPLICATION.value and str(self.payer_id) in settings.ALLOWED_NEGATIVE_BALANCE_APPLICATIONS
# if self.amount > payer.balance and not payer_is_allowed_negative_balance :
# raise APIException('not enough balance')
# payer.balance = F('balance') - self.amount
# payer.save(update_fields=['balance'])
#
# def deposit_to_payee_balance(self):
# payee = self.get_payee()
# payee.balance = F('balance') + self.amount
# payee.save(update_fields=['balance'])
#
# def get_pay_entity(self, entity_id, entity_type):
# if entity_type == TypeChoices.APPLICATION:
# entity: Application = Application.objects.get(pk=entity_id)
# else:
# entity: User = User.objects.get(pk=entity_id)
#
# return entity
#
# def get_payer(self):
# return self.get_pay_entity(self.payer_id, self.payer_type)
#
# def get_payee(self):
# return self.get_pay_entity(self.payee_id, self.payee_type)
#
# def change_state(self, from_state, to_state, error_message=None, **data):
# if self.state == from_state:
# updated = Transaction.objects.filter(pk=self.pk, state=from_state).update(
# state=to_state,
# **data
# )
# if updated: # double check in db level
# self.state = to_state
# else:
# raise APIException(error_message)
# else:
# raise APIException(error_message)
#
# def pay(self, payer_id=None, payer_type=None):
# if not self.payer_id and not (payer_id and payer_type):
# raise APIException('payer identification error')
#
# if self.payer_id and payer_id and not (self.payer_id == payer_id and self.payer_type == payer_type):
# raise APIException('payer identification error')
#
# if not self.state == StateChoices.CREATED:
# raise APIException('this transaction is not ready to pay')
#
# if not self.payer_id:
# self.payer_id = payer_id
# self.payer_type = payer_type
#
# payer = self.get_payer()
#
# if payer.balance < self.amount:
# raise APIException('not enough balance')
#
# with transaction.atomic():
# if self.delay:
# self.change_state(
# StateChoices.CREATED,
# StateChoices.DELAYED,
# error_message='this transaction is not ready to pay',
# payer_id=payer_id,
# payer_type=payer_type
# )
#
# else:
# self.change_state(
# StateChoices.CREATED,
# StateChoices.SUCCESS,
# error_message='this transaction is not ready to pay',
# payer_id=payer_id,
# payer_type=payer_type
# )
# self.deposit_to_payee_balance()
#
# self.withdraw_from_payer_balance()
# return self
#
# def receipt(self):
# if not self.state == StateChoices.DELAYED:
# raise APIException('this transaction is not delayed')
#
# with transaction.atomic():
# self.change_state(
# StateChoices.DELAYED,
# StateChoices.SUCCESS,
# error_message='this transaction is not delayed'
# )
# self.deposit_to_payee_balance()
# return self
#
# def submit(self):
# payer = self.get_payer()
# payee = self.get_payee()
#
# if not payer:
# raise APIException('payer identification error')
#
# if not payee:
# raise APIException('payee identification error')
#
# if not self.state == StateChoices.CREATED:
# raise APIException('this transaction is not ready to pay')
#
# with transaction.atomic():
# self.withdraw_from_payer_balance()
# self.change_state(
# StateChoices.CREATED,
# StateChoices.PENDING,
# error_message='this transaction is not ready to pay'
# )
# return self
#
# def verify(self):
# if not self.state == StateChoices.PENDING:
# raise APIException('this transaction is not pending')
#
# with transaction.atomic():
# self.deposit_to_payee_balance()
# self.change_state(
# StateChoices.PENDING,
# StateChoices.SUCCESS,
# error_message='this transaction is not pending'
# )
# return self
#
# def get_role(self, entity):
#
# if self.payer_id == entity.uuid:
# return 1
# elif self.payee_id == entity.uuid:
# return 2
# else:
# return 0
#
# def update_details(self, force_update=False):
# if not isinstance(self.details, dict):
# self.details = {}
# self.save()
# # detail = {'payer_name', 'payee_name', 'application_name', 'payer_avatar_url', 'payee_avatar_url', 'application_avatar_url'}
# is_updated = False
# if not self.details.get('payer_name'):
# if self.payer_type == TypeChoices.USER.value:
# payer_details: PublicUser = get_user_details(self.payer_id)
# self.details['payer_name'] = payer_details.first_name + payer_details.last_name
# self.details['payer_avatar_url'] = payer_details.avatar
# else:
# payer_details = get_application_details(self.payer_id)
# self.details['payer_name'] = payer_details.name
# #TODO: get avatar from details
#
# is_updated = True
#
# if not self.details.get('payee_name'):
# if self.payee_type == TypeChoices.USER.value:
# payee_details: PublicUser = get_user_details(self.payee_id)
# self.details['payee_name'] = payee_details.first_name + payee_details.last_name
# self.details['payee_avatar_url'] = payee_details.avatar
# else:
# payee_details = get_application_details(self.payee_id)
# self.details['payee_name'] = payee_details.name
# #TODO: get avatar from details
#
# is_updated = True
#
# if not self.details.get('application_name'):
# application_details = get_application_details(self.application_id)
# try:
# self.details['application_name'] = application_details.name
# # TODO: get avatar from details
#
# is_updated = True
# except Exception as e:
# pass
#
# if is_updated:
# self.save()
#
#
# def __str__(self):
# try:
# return f"{self.get_payer()} -> {self.get_payee()} ({self.uuid}): {self.amount} - {self.get_state_display()}"
# except:
# return str(self.uuid)

View file

@ -38,6 +38,21 @@ application_urlpatterns = [
path('api/application/', application_views.TransactionListAPIView.as_view(), name='application_transaction_list_api'), path('api/application/', application_views.TransactionListAPIView.as_view(), name='application_transaction_list_api'),
path('api/application/<uuid>/', application_views.TransactionDetailAPIView.as_view(), name='application_transaction_detail'), path('api/application/<uuid>/', application_views.TransactionDetailAPIView.as_view(), name='application_transaction_detail'),
path('api/application/<category_uuid>/balance', application_views.CategoryBalanceAPIView.as_view(), name='application_wallet_balance_api'),
path('api/application/<category_uuid>/user_balance/<uuid>', application_views.CategoryUserBalanceAPIView.as_view(), name='application_user_wallet_balance_api'),
path('api/application/<category_uuid>/deposit/', application_views.CategoryDepositSubmitAPIView.as_view(), name='application_deposit_submit_api'),
path('api/application/<category_uuid>/deposit/<uuid>/verify', application_views.CategoryDepositVerifyAPIView.as_view(), name='application_deposit_verify_api'),
path('api/application/<category_uuid>/withdraw/', application_views.CategoryWithdrawSubmitAPIView.as_view(), name='application_withdraw_submit_api'),
path('api/application/<category_uuid>/withdraw/<uuid>/verify', application_views.CategoryWithdrawVerifyAPIView.as_view(), name='application_withdraw_verify_api'),
path('api/application/<category_uuid>/', application_views.CategoryTransactionListAPIView.as_view(), name='application_transaction_list_api'),
path('api/application/<category_uuid>/<uuid>/', application_views.CategoryTransactionDetailAPIView.as_view(), name='application_transaction_detail'),
] ]
urlpatterns = user_urlpatterns + application_urlpatterns urlpatterns = user_urlpatterns + application_urlpatterns

View file

@ -3,11 +3,12 @@ from oauth2_provider.contrib.rest_framework import TokenHasScope, IsAuthenticate
from rest_framework import generics, permissions, mixins from rest_framework import generics, permissions, mixins
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.users.models import User from apps.users.models import User
from apps.wallet.constans import TypeChoices, StateChoices from apps.wallet.constans import TypeChoices, StateChoices
from apps.wallet.models import Transaction from apps.wallet.models import Transaction, Category, Wallet
from apps.wallet.serializers import ApplicationDepositSerializer, ApplicationWithdrawSerializer, \ from apps.wallet.serializers import ApplicationDepositSerializer, ApplicationWithdrawSerializer, \
ApplicationTransactionSerializer, ApplicationBalanceSerializer, MyWalletSerializer ApplicationTransactionSerializer, ApplicationBalanceSerializer, MyWalletSerializer
@ -20,6 +21,10 @@ def get_application(request):
return application return application
def get_wallet_category(request: Request):
pass
class BalanceAPIView(generics.RetrieveAPIView): class BalanceAPIView(generics.RetrieveAPIView):
''' '''
API for getting specified user or application balance. API for getting specified user or application balance.
@ -75,6 +80,7 @@ class TransactionListAPIView(generics.ListAPIView):
Q(payee_id=application.pk, payee_type=TypeChoices.APPLICATION.value) Q(payee_id=application.pk, payee_type=TypeChoices.APPLICATION.value)
).all() ).all()
# from application to another entity # from application to another entity
# TODO: better name # TODO: better name
class DepositSubmitAPIView(generics.CreateAPIView): class DepositSubmitAPIView(generics.CreateAPIView):
@ -165,3 +171,172 @@ class WithdrawVerifyAPIView(generics.RetrieveAPIView):
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
return Response(serializer.data) return Response(serializer.data)
class CategoryBalanceAPIView(generics.RetrieveAPIView):
'''
API for getting specified user or application balance.
'''
permission_classes = [TokenHasScope]
serializer_class = ApplicationBalanceSerializer
required_scopes = ['wallet.application:get_balance']
def get_object(self):
category_uuid = self.kwargs.get('category_uuid')
category = get_object_or_404(Category.objects.all(), uuid=category_uuid)
application = get_application(self.request)
wallet, created = Wallet.objects.get_or_create(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION,
category=category)
return wallet
class CategoryUserBalanceAPIView(generics.RetrieveAPIView):
'''
API for getting specified user or application balance.
'''
permission_classes = [IsAuthenticatedOrTokenHasScope]
serializer_class = MyWalletSerializer
required_scopes = ['wallet.application:get_user_balance']
def get_object(self):
category_uuid = self.kwargs.get('category_uuid')
category = get_object_or_404(Category.objects.all(), uuid=category_uuid)
user_uuid = self.kwargs.get('uuid')
user, created = User.objects.get_or_create(uuid=user_uuid)
application = get_application(self.request)
wallet, created = Wallet.objects.get_or_create(owner_uuid=user_uuid, owner_type=TypeChoices.USER,
category=category)
return wallet
class CategoryTransactionDetailAPIView(generics.RetrieveAPIView):
permission_classes = [TokenHasScope]
serializer_class = ApplicationTransactionSerializer
lookup_field = 'uuid'
required_scopes = ['wallet.application:get_transaction_detail']
def get_queryset(self):
application = get_application(self.request)
return Transaction.objects.filter(Q(application=application) |
Q(payer_wallet__owner_uuid=application.pk,
payer_wallet__owner_type=TypeChoices.APPLICATION) |
Q(payee_wallet__owner_uuid=application.pk,
payee_wallet__owner_type=TypeChoices.APPLICATION)
).all()
class CategoryTransactionListAPIView(generics.ListAPIView):
permission_classes = [TokenHasScope]
serializer_class = ApplicationTransactionSerializer
lookup_field = 'uuid'
required_scopes = ['wallet.application:get_transaction_list']
def get_queryset(self):
application = get_application(self.request)
return Transaction.objects.filter(Q(application=application) |
Q(payer_wallet__owner_uuid=application.pk,
payer_wallet__owner_type=TypeChoices.APPLICATION) |
Q(payee_wallet__owner_uuid=application.pk,
payee_wallet__owner_type=TypeChoices.APPLICATION)
).all()
# from application to another entity
# TODO: better name
class CategoryDepositSubmitAPIView(generics.CreateAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope]
serializer_class = ApplicationDepositSerializer
required_scopes = ['wallet.application.deposit:submit']
def get_queryset(self):
category_uuid = self.kwargs.get('category_uuid')
category = get_object_or_404(Category.objects.all(), uuid=category_uuid)
application = get_application(self.request)
wallet, created = Wallet.objects.get_or_create(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION,
category=category)
return Transaction.objects.filter(application=application,
payer_wallet__owner_uuid=application.pk,
payer_wallet__owner_type=TypeChoices.APPLICATION).all()
def perform_create(self, serializer):
# TODO: get_or_create instead of create if uuid given
# TODO: it is possible to merage create and submit in one step for improve performance
application = get_application(self.request)
category = get_object_or_404(Category.objects.all(), uuid=self.kwargs.get('category_uuid'))
wallet = Wallet.objects.get_or_create(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION, category=category)
instance = serializer.save(payer_wallet=wallet,
application=application)
try:
instance.submit()
except Exception as e:
print(e)
APIException('Failed to submit deposit')
class CategoryDepositVerifyAPIView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope]
serializer_class = ApplicationDepositSerializer
lookup_field = 'uuid'
required_scopes = ['wallet.application.deposit:verify']
def get_queryset(self):
application = get_application(self.request)
category = get_object_or_404(Category.objects.all(), uuid=self.kwargs.get('category_uuid'))
wallet = Wallet.objects.get(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION, category=category)
return Transaction.objects.filter(application=application,
payer_wallet=wallet).all()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if instance.state == StateChoices.PENDING:
instance.verify()
serializer = self.get_serializer(instance)
return Response(serializer.data)
# from another entity to application
class CategoryWithdrawSubmitAPIView(generics.CreateAPIView):
permission_classes = [TokenHasScope]
serializer_class = ApplicationWithdrawSerializer
required_scopes = ['wallet.application.withdraw:submit']
def get_queryset(self):
category_uuid = self.kwargs.get('category_uuid')
category = get_object_or_404(Category.objects.all(), uuid=category_uuid)
application = get_application(self.request)
wallet, created = Wallet.objects.get_or_create(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION,
category=category)
return Transaction.objects.filter(application=application, wallet=wallet).all()
def perform_create(self, serializer):
# TODO: get_or_create instead of create if uuid given
# TODO: it is possible to merage create and submit in one step for improve performance
application = get_application(self.request)
category = get_object_or_404(Category.objects.all(), uuid=self.kwargs.get('category_uuid'))
wallet = Wallet.objects.get_or_create(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION, category=category)
instance = serializer.save(payee_wallet=wallet, application=application)
instance.submit()
class CategoryWithdrawVerifyAPIView(generics.RetrieveAPIView):
permission_classes = [TokenHasScope]
serializer_class = ApplicationWithdrawSerializer
lookup_field = 'uuid'
required_scopes = ['wallet.application.withdraw:verify']
def get_queryset(self):
application = get_application(self.request)
category = get_object_or_404(Category.objects.all(), uuid=self.kwargs.get('category_uuid'))
wallet = Wallet.objects.get(owner_uuid=application.uuid, owner_type=TypeChoices.APPLICATION, category=category)
return Transaction.objects.filter(application=application,
payee_wallet=wallet).all()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if instance.state == StateChoices.PENDING:
instance.verify()
serializer = self.get_serializer(instance)
return Response(serializer.data)

15
utils/models.py Normal file
View file

@ -0,0 +1,15 @@
import json
import uuid
from datetime import datetime
from django.db import models
from django.utils.translation import gettext_lazy as _
class BaseModel(models.Model):
id = None
uuid = models.UUIDField(primary_key=True, editable=True, default=uuid.uuid4, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True, db_index=True)
class Meta:
abstract = True