transactions
This commit is contained in:
parent
c8276e20c3
commit
961e11015a
11 changed files with 305 additions and 7 deletions
|
|
@ -18,6 +18,7 @@ from django.conf.urls.static import static
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.transactions.views import TransactionList, TransactionDetail, TransactionPay, TransactionReceipt
|
||||||
from apps.users.views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView
|
from apps.users.views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView
|
||||||
from django.contrib.auth import urls as auth_urls
|
from django.contrib.auth import urls as auth_urls
|
||||||
|
|
||||||
|
|
@ -32,6 +33,11 @@ urlpatterns = [
|
||||||
path('account/', AccountView.as_view(), name='account'),
|
path('account/', AccountView.as_view(), name='account'),
|
||||||
path('request_otp/', RequestOTPView.as_view(), name='register'),
|
path('request_otp/', RequestOTPView.as_view(), name='register'),
|
||||||
path('change_password/', ChangePasswordView.as_view(), name='change_password'),
|
path('change_password/', ChangePasswordView.as_view(), name='change_password'),
|
||||||
|
|
||||||
|
path('transactions/', TransactionList.as_view(), name='transactions'),
|
||||||
|
path('transactions/<code>', TransactionDetail.as_view(), name='transaction_detail'),
|
||||||
|
path('transactions/<code>/pay', TransactionPay.as_view(), name='transaction_pay'),
|
||||||
|
path('transactions/<code>/receipt', TransactionReceipt.as_view(), name='transaction_receipt'),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
||||||
0
apps/transactions/__init__.py
Executable file
0
apps/transactions/__init__.py
Executable file
19
apps/transactions/admin.py
Executable file
19
apps/transactions/admin.py
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Transaction
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionAdmin(admin.ModelAdmin):
|
||||||
|
fields = [
|
||||||
|
'code',
|
||||||
|
'amount',
|
||||||
|
'payer',
|
||||||
|
'payee',
|
||||||
|
'create',
|
||||||
|
'last_update',
|
||||||
|
'state',
|
||||||
|
'delay'
|
||||||
|
]
|
||||||
|
readonly_fields = ['last_update', 'create']
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Transaction, TransactionAdmin)
|
||||||
5
apps/transactions/apps.py
Executable file
5
apps/transactions/apps.py
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionsConfig(AppConfig):
|
||||||
|
name = 'apps.transactions'
|
||||||
32
apps/transactions/migrations/0001_initial.py
Normal file
32
apps/transactions/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 3.0 on 2019-12-06 20:24
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Transaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('code', models.IntegerField(verbose_name='code')),
|
||||||
|
('amount', models.IntegerField(verbose_name='amount')),
|
||||||
|
('delay', models.IntegerField(blank=True, default=0)),
|
||||||
|
('state', models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'failed'), (5, 'done')], default=1)),
|
||||||
|
('create', models.DateTimeField(auto_now=True, null=True, verbose_name='create')),
|
||||||
|
('last_update', models.DateTimeField(auto_now=True, null=True, verbose_name='last update')),
|
||||||
|
('detail_id', models.IntegerField(blank=True, null=True, unique=True)),
|
||||||
|
('payee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('payer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/transactions/migrations/__init__.py
Executable file
0
apps/transactions/migrations/__init__.py
Executable file
132
apps/transactions/models.py
Executable file
132
apps/transactions/models.py
Executable file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import F
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from apps.users.models import User
|
||||||
|
from django.db import transaction
|
||||||
|
from model_utils.choices import Choices
|
||||||
|
from rest_framework.exceptions import APIException, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionManager(models.Manager):
|
||||||
|
def create_transaction(self,
|
||||||
|
amount,
|
||||||
|
payee: User,
|
||||||
|
payer: User = None,
|
||||||
|
delay=0,
|
||||||
|
detail_id=None,
|
||||||
|
state=None):
|
||||||
|
if not (isinstance(payer, User) or payer is None):
|
||||||
|
raise ValidationError('payer type not valid')
|
||||||
|
if not isinstance(payee, User):
|
||||||
|
raise ValidationError('payee type not valid')
|
||||||
|
if not amount > 0:
|
||||||
|
raise ValidationError('amount should be greater than zero')
|
||||||
|
if not delay >= 0:
|
||||||
|
raise ValidationError('delay should be greater than equal zero')
|
||||||
|
|
||||||
|
transaction = self.model(payer=payer,
|
||||||
|
payee=payee,
|
||||||
|
amount=amount,
|
||||||
|
delay=delay,
|
||||||
|
detail_id=detail_id
|
||||||
|
)
|
||||||
|
if state:
|
||||||
|
transaction.state = state
|
||||||
|
|
||||||
|
transaction.set_code()
|
||||||
|
transaction.save()
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(models.Model):
|
||||||
|
STATE_CHOICES = Choices(
|
||||||
|
(1, 'created', 'created'),
|
||||||
|
(2, 'delayed', 'delayed'),
|
||||||
|
(3, 'pending', 'pending'),
|
||||||
|
(4, 'failed', 'failed'),
|
||||||
|
(5, 'done', 'done'),
|
||||||
|
)
|
||||||
|
|
||||||
|
code = models.IntegerField(_('code'), blank=False) # unique random number
|
||||||
|
amount = models.IntegerField(_('amount'), blank=False)
|
||||||
|
delay = models.IntegerField(default=0, blank=True) # number of days for delayed payments
|
||||||
|
payer = models.ForeignKey(User, on_delete=models.PROTECT, related_name='payments', blank=True, null=True)
|
||||||
|
payee = models.ForeignKey(User, on_delete=models.PROTECT, related_name='receipts')
|
||||||
|
state = models.IntegerField(choices=STATE_CHOICES, default=STATE_CHOICES.created)
|
||||||
|
create = models.DateTimeField(_('create'), blank=True, null=True, auto_now=True)
|
||||||
|
last_update = models.DateTimeField(_('last update'), blank=True, null=True, auto_now=True)
|
||||||
|
type = None # invoice, charge, etc. it may be redundant
|
||||||
|
detail_id = models.IntegerField(null=True, blank=True, unique=True) # or better detail url or res number
|
||||||
|
|
||||||
|
objects = TransactionManager()
|
||||||
|
|
||||||
|
def set_code(self):
|
||||||
|
# TODO: set infinite algorithm
|
||||||
|
for code in random.sample(range(10000, 100000), 90000):
|
||||||
|
try:
|
||||||
|
code = str(code)
|
||||||
|
self.code = code
|
||||||
|
return self.save()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise APIException('system error')
|
||||||
|
|
||||||
|
def pay(self, payer=None):
|
||||||
|
if not self.payer and not payer:
|
||||||
|
raise APIException('payer identification error')
|
||||||
|
|
||||||
|
if self.payer and payer and not self.payer == payer:
|
||||||
|
raise APIException('payer identification error')
|
||||||
|
|
||||||
|
if not self.payer:
|
||||||
|
self.payer = payer
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# TODO: check bundry conditions
|
||||||
|
self.last_update = datetime.datetime.now()
|
||||||
|
|
||||||
|
if not self.state == self.STATE_CHOICES.created:
|
||||||
|
raise APIException('this transaction is not ready to pay')
|
||||||
|
|
||||||
|
if self.payer.balance < self.amount:
|
||||||
|
raise APIException('not enough balance')
|
||||||
|
|
||||||
|
self.payer.balance = F('balance') - self.amount
|
||||||
|
self.payer.save(update_fields=['balance'])
|
||||||
|
|
||||||
|
self.payer.refresh_from_db()
|
||||||
|
if self.payer.balance < 0:
|
||||||
|
raise APIException('not enough balance')
|
||||||
|
|
||||||
|
if self.delay:
|
||||||
|
self.state = self.STATE_CHOICES.delayed
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.payee.balance = F('balance') + self.amount
|
||||||
|
self.payee.save(update_fields=['balance'])
|
||||||
|
|
||||||
|
self.state = self.STATE_CHOICES.done
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def receipt(self):
|
||||||
|
with transaction.atomic():
|
||||||
|
self.last_update = datetime.datetime.now()
|
||||||
|
# TODO: check bundry conditions
|
||||||
|
if not self.state == self.STATE_CHOICES.delayed:
|
||||||
|
raise APIException('this transaction is not delayed')
|
||||||
|
|
||||||
|
self.payee.balance = F('balance') + self.amount
|
||||||
|
self.state = self.STATE_CHOICES.done
|
||||||
|
self.payee.save()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.payer} -> {self.payee} ({self.code}): {self.amount}"
|
||||||
41
apps/transactions/serializers.py
Executable file
41
apps/transactions/serializers.py
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from django.db.transaction import atomic
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.users.serializers import PublicUserSerializer
|
||||||
|
from .models import Transaction
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionSerializer(serializers.ModelSerializer):
|
||||||
|
payer = PublicUserSerializer(required=False)
|
||||||
|
payee = PublicUserSerializer(required=False, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Transaction
|
||||||
|
fields = ('payer',
|
||||||
|
'payee',
|
||||||
|
"create",
|
||||||
|
"state",
|
||||||
|
"code",
|
||||||
|
'amount',
|
||||||
|
'delay',
|
||||||
|
'last_update',
|
||||||
|
'detail_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
read_only_fields = ['last_update', 'code', 'state', 'create', 'detail_id']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
|
||||||
|
payer = validated_data.get('payer')
|
||||||
|
request = self.context.get('request')
|
||||||
|
user = request.user if request else payer
|
||||||
|
|
||||||
|
if user == payer:
|
||||||
|
with atomic():
|
||||||
|
transaction = Transaction.objects.create_transaction(**validated_data)
|
||||||
|
transaction.pay()
|
||||||
|
|
||||||
|
else:
|
||||||
|
transaction = Transaction.objects.create_transaction(**validated_data)
|
||||||
|
|
||||||
|
return transaction
|
||||||
3
apps/transactions/tests.py
Executable file
3
apps/transactions/tests.py
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
67
apps/transactions/views.py
Executable file
67
apps/transactions/views.py
Executable file
|
|
@ -0,0 +1,67 @@
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from oauth2_provider.contrib.rest_framework import TokenHasScope, IsAuthenticatedOrTokenHasScope
|
||||||
|
from rest_framework import generics, permissions
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
from apps.transactions.serializers import TransactionSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionList(generics.ListCreateAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope]
|
||||||
|
serializer_class = TransactionSerializer
|
||||||
|
required_scopes = []
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
return Transaction.objects.filter(Q(payee_id=user.id) | Q(payer_id=user.id)).all()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
user = self.request.user
|
||||||
|
serializer.save(payee=user)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionDetail(generics.RetrieveAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope]
|
||||||
|
serializer_class = TransactionSerializer
|
||||||
|
lookup_field = 'code'
|
||||||
|
required_scopes = []
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
return Transaction.objects.filter(Q(payee_id=user.id) | Q(payer_id=user.id) | Q(payer__isnull=True)).all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionPay(generics.RetrieveAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope]
|
||||||
|
serializer_class = TransactionSerializer
|
||||||
|
lookup_field = 'code'
|
||||||
|
required_scopes = []
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
return Transaction.objects.filter(Q(payer_id=user.id) | Q(payer__isnull=True)).all()
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
instance.pay(payer=self.request.user)
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionReceipt(generics.RetrieveAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope]
|
||||||
|
serializer_class = TransactionSerializer
|
||||||
|
lookup_field = 'code'
|
||||||
|
required_scopes = []
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
return Transaction.objects.filter(payer_id=user.id).all()
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
instance.receipt()
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
7
utils.py
Normal file → Executable file
7
utils.py
Normal file → Executable file
|
|
@ -1,5 +1,3 @@
|
||||||
import random
|
|
||||||
from time import time
|
|
||||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
@ -7,11 +5,6 @@ from django.contrib.auth import get_user_model
|
||||||
USER_MODEL = get_user_model()
|
USER_MODEL = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
def random_code(pre_len=5, post_len=5):
|
|
||||||
code = str(time())[:pre_len] + ''.join(random.choice('0123456789') for _ in range(post_len))
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223
|
class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223
|
||||||
""" Primarily extend the functionality of token generation """
|
""" Primarily extend the functionality of token generation """
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue