Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create and base config payments service project #4

Closed
wants to merge 12 commits into from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ poetry.lock

venv

.env*
.env.dev
.env.prod

Expand Down
Empty file added backend/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions backend/payments/.pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
repos:
- repo: https://github.com/pycqa/flake8.git
rev: 6.0.0
hooks:
- id: flake8
args: [
"--ignore=E501"
]
additional_dependencies: [
"flake8-bugbear",
"flake8-builtins",
"pep8-naming",
"flake8-commas",
"flake8-quotes",
"flake8-todo",
"flake8-django",
"flake8-cognitive-complexity",
"flake8-isort",
]
56 changes: 56 additions & 0 deletions backend/payments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# GSpot Payments

REST API сервис для работы с финансами интернет магазина видеоигр GSpot


## Как запустить local-версию

TODO

## Переменные окружения

Образ Django считывает настройки из переменных окружения:
- `SECRET_KEY` - соль для генерации хэшей. Значение может быть произвольной строкой
- `DEBUG`- настройка Django для ключения отладочного режима.
- `ALLOWED_HOSTS` - список разрешенных хостов.
- `DATABASE_URL` - адрес для подключения к БД PostgreSQL. [Формат записи](https://github.com/jacobian/dj-database-url#url-schema)
- `account_id` - id вашего магазина yookassa.
- `shop_secret_key` - api ключ вашего магазина yookassa.

## Установка и настройка flake8
На проекте используется линтер flake8 с плагинами. Flake8 не дает возможности перечислить используемые плагины,
поэтому если у вас есть другие проекты с flake8, то их плагины начнут мешать друг другу. Чтобы избежать этого
необходимо создать отдельное виртуальное окружение под линтер.

Устанавливаем flake8 и плагины для него:
```shell
virtualenv venv
source ../venv/bin/activate
pip install -r flake8-requirements.txt
```

Настройте IDE для использования flake8 из виртуального окружения (не из глобального):
- Pycharm - [статья 1](https://melevir.medium.com/pycharm-loves-flake-671c7fac4f52), [статья 2](https://habr.com/en/company/dataart/blog/318776/);
- [Vscode](https://stackoverflow.com/questions/54160207/using-flake8-in-vscode/54160321#54160321).

### Насторойка flake8 для запуска проверки в pre-commit hooks

Установить pre-commit:
```shell
pre-commit install
```

В последующем все проиндексированные файлы будут проверяться с помощью flake8 перед коммитом.

Для проверки с помощью flake8 файлов проиндексированных для коммита выполнить:
```shell
pre-commit run
```
В случае необходимости пропустить использование pre-commit hook коммит нужно выполнить с флагом `-n` или `--no-verify`:
```shell
git commit --no-verify
```
Для автоматического обновления версии hooks, используемых в pre-commit выполнить:
```shell
pre-commit autoupdate
```
Empty file added backend/payments/__init__.py
Empty file.
Empty file.
Empty file.
3 changes: 3 additions & 0 deletions backend/payments/apps/payment_accounts/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
7 changes: 7 additions & 0 deletions backend/payments/apps/payment_accounts/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class PaymentAccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.payment_accounts'
verbose_name = 'Счета пользователя'
Empty file.
95 changes: 95 additions & 0 deletions backend/payments/apps/payment_accounts/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

from decimal import Decimal

from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.shortcuts import get_object_or_404


# Create your models here.
def is_amount_positive(method):
def wrapper(cls, *args, **kwargs):
amount = kwargs['amount']
if amount < 0:
raise ValueError('Should be positive value')
return method(cls, *args, **kwargs)
return wrapper


class Account(models.Model):

user_uid = models.UUIDField(unique=True, editable=False, db_index=True)
balance = models.DecimalField(
decimal_places=2,
max_digits=settings.MAX_BALANCE_DIGITS,
validators=[MinValueValidator(0, message='Insufficient Funds')],
default=0,
)

@classmethod
@is_amount_positive
def deposit(cls, *, pk: int, amount: Decimal) -> Account:
"""
Use a classmethod instead of an instance method,
to acquire the lock we need to tell the database
to lock it, preventing data update collisions.
When operating on self the object is already fetched.
And we don't have any guaranty that it was locked.
"""
with transaction.atomic():
account = get_object_or_404(
cls.objects.select_for_update(),
pk=pk,
)
account.balance += amount
account.save()
return account

@classmethod
@is_amount_positive
def withdraw(cls, *, pk: int, amount: Decimal) -> Account:
with transaction.atomic():
account = get_object_or_404(
cls.objects.select_for_update(),
pk=pk,
)
account.balance -= amount
account.save()
return account

def __str__(self) -> str:
return f'User id: {self.user_uid}'


class BalanceChange(models.Model):
class TransactionType(models.TextChoices):
WITHDRAW = ('WD', 'WITHDRAW')
DEPOSIT = ('DT', 'DEPOSIT')

account_id = models.ForeignKey(
Account, on_delete=models.PROTECT,
related_name='balance_changes',
)
amount = models.DecimalField(
max_digits=settings.MAX_BALANCE_DIGITS,
validators=[MinValueValidator(0, message='Should be positive value')],
decimal_places=2,
editable=False,
)
date_time_creation = models.DateTimeField(
auto_now_add=True,
editable=False,
db_index=True,
)
is_accepted = models.BooleanField(default=False)
operation_type = models.CharField(max_length=20, choices=TransactionType.choices)

def __str__(self) -> str:
return f'Account id: {self.account_id} ' \
f'Date time of creation: {self.date_time_creation}' \
f'Amount: {self.amount}'

class Meta:
ordering = ['-date_time_creation']
19 changes: 19 additions & 0 deletions backend/payments/apps/payment_accounts/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.conf import settings
from django.core.validators import MinValueValidator
from rest_framework import serializers


class CreatePaymentSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
value = serializers.DecimalField(
decimal_places=2,
max_digits=settings.MAX_BALANCE_DIGITS,
validators=[MinValueValidator(0, message='Insufficient Funds')],
)
commission = serializers.DecimalField(
decimal_places=1,
max_digits=settings.MAX_BALANCE_DIGITS,
validators=[MinValueValidator(0, message='Insufficient Funds')],
)
payment_type = serializers.CharField(max_length=20)
return_url = serializers.URLField()
47 changes: 47 additions & 0 deletions backend/payments/apps/payment_accounts/services/create_payment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from environs import Env
from yookassa import Configuration, Payment

from ..models import Account, BalanceChange

env = Env()
env.read_env()
Configuration.account_id = env.int('account_id')
Configuration.secret_key = env.str('shop_secret_key')


def create_payment(uuid, value, commission, payment_type, return_url):
value_with_commission = value * (1 / (1 - commission / 100))
user_account, created = Account.objects.get_or_create(
user_uid=uuid,
)

change = BalanceChange.objects.create(
account_id=user_account,
amount=value,
is_accepted=False,
operation_type='DEPOSIT',
)
change.save()

payment = Payment.create({
'amount': {
'value': value_with_commission,
'currency': 'RUB',
},
'payment_method_data': {
'type': payment_type,
},
'confirmation': {
'type': 'redirect',
'return_url': return_url,
},
'metadata': {
'table_id': change.id,
'user_id': user_account.id,
},
'capture': True,
'refundable': False,
'description': 'Пополнение на ' + str(value),
})

return payment.confirmation.confirmation_url
3 changes: 3 additions & 0 deletions backend/payments/apps/payment_accounts/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
8 changes: 8 additions & 0 deletions backend/payments/apps/payment_accounts/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.urls import path

from . import views

urlpatterns = [
path('create_payment/', views.CreatePaymentView.as_view()),
path('payment_acceptance/', views.CreatePaymentAcceptanceView.as_view()),
]
64 changes: 64 additions & 0 deletions backend/payments/apps/payment_accounts/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import json
from decimal import Decimal

import rollbar
from django.core.exceptions import ObjectDoesNotExist
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response

from .models import Account, BalanceChange
from .serializers import CreatePaymentSerializer
from .services.create_payment import create_payment


class CreatePaymentView(CreateAPIView):
serializer_class = CreatePaymentSerializer

def post(self, request, *args, **kwargs):
serializer = CreatePaymentSerializer(data=request.POST)

if serializer.is_valid():
serialized_data = serializer.validated_data
else:
return Response(400)

confirmation_url = create_payment(
uuid=serialized_data.get('uuid'),
value=serialized_data.get('value'),
commission=serialized_data.get('commission'),
payment_type=serialized_data.get('payment_type'),
return_url=serialized_data.get('return_url'),
)

return Response({'confirmation_url': confirmation_url}, 200)


class CreatePaymentAcceptanceView(CreateAPIView):

def post(self, request, *args, **kwargs):
response = json.loads(request.body)

try:
table = BalanceChange.objects.get(
id=response['object']['metadata']['table_id'],
)
except ObjectDoesNotExist:
rollbar.report_message(
"Can't get table for payment id {0}".format(
response['object']['id'],
),
'warning',
)
return Response(404)

if response['event'] == 'payment.succeeded':
table.is_accepted = True
table.save()
Account.deposit(
pk=response['object']['metadata']['user_id'],
amount=Decimal(response['object']['income_amount']['value']),
)
elif response['event'] == 'payment.canceled':
table.delete()

return Response(200)
Empty file.
3 changes: 3 additions & 0 deletions backend/payments/apps/transactions/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
7 changes: 7 additions & 0 deletions backend/payments/apps/transactions/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class TransactionsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.transactions'
vebose_name = 'Транзакции'
2 changes: 2 additions & 0 deletions backend/payments/apps/transactions/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class DuplicateError(Exception):
pass
Empty file.
Loading