diff --git a/.pylintrc b/.pylintrc index d04bcc3..47fd4d5 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,6 @@ [MASTER] disable=no-member, + invalid-name, unnecessary-pass, useless-option-value, too-few-public-methods, diff --git a/lib/payme/admin.py b/lib/payme/admin.py index 7928501..147a5ea 100644 --- a/lib/payme/admin.py +++ b/lib/payme/admin.py @@ -1,11 +1,17 @@ from django.contrib import admin - -from payme.models import CUSTOM_ORDER -from payme.models import Order as DefaultOrderModel - -from payme.models import MerchantTransactionsModel - -if not CUSTOM_ORDER: - admin.site.register(DefaultOrderModel) +from payme.models import ( + FiscalData, MerchantTransactionsModel, PaymeItem, + PaymeOrder, ShippingDetail, OrderItem +) admin.site.register(MerchantTransactionsModel) +admin.site.register( + [ + MerchantTransactionsModel, + FiscalData, + PaymeItem, + PaymeOrder, + OrderItem, + ShippingDetail + ] +) diff --git a/lib/payme/errors/exceptions.py b/lib/payme/errors/exceptions.py index 7b514cd..936a39a 100644 --- a/lib/payme/errors/exceptions.py +++ b/lib/payme/errors/exceptions.py @@ -87,3 +87,10 @@ class PaymeTimeoutException(Exception): """ Payme timeout exception that means that payme is working slowly. """ + + +class QRCodeError(Exception): + """ + QRCodeError Exception \ + Raises when an error occurs while generating a QR code. + """ diff --git a/lib/payme/methods/check_perform_transaction.py b/lib/payme/methods/check_perform_transaction.py index b6e002d..0a5c27a 100644 --- a/lib/payme/methods/check_perform_transaction.py +++ b/lib/payme/methods/check_perform_transaction.py @@ -1,5 +1,6 @@ -from payme.utils.get_params import get_params +from payme.models import PaymeOrder as Order from payme.serializers import MerchantTransactionsModelSerializer +from payme.utils.get_params import clean_empty, get_params class CheckPerformTransaction: @@ -17,10 +18,16 @@ def __call__(self, params: dict) -> tuple: ) serializer.is_valid(raise_exception=True) + order = Order.objects.get( + pk=serializer.validated_data.get('order_id') + ) + detail = clean_empty(order.to_detail()) + response = { "result": { "allow": True, - } + "detail": detail } + } return None, response diff --git a/lib/payme/methods/generate_link.py b/lib/payme/methods/generate_link.py index 173be8a..6fcce11 100644 --- a/lib/payme/methods/generate_link.py +++ b/lib/payme/methods/generate_link.py @@ -1,9 +1,18 @@ import base64 -from decimal import Decimal +import json +import os +import typing +import uuid from dataclasses import dataclass +from decimal import Decimal + +import websocket +from websocket import WebSocketApp from django.conf import settings +from payme.errors.exceptions import QRCodeError + PAYME_ID = settings.PAYME.get('PAYME_ID') PAYME_ACCOUNT = settings.PAYME.get('PAYME_ACCOUNT') PAYME_CALL_BACK_URL = settings.PAYME.get('PAYME_CALL_BACK_URL') @@ -38,6 +47,11 @@ class GeneratePayLink: def generate_link(self) -> str: """ GeneratePayLink for each order. + + Full method documentation + ---------- + https://developer.help.paycom.uz/initsializatsiya-platezhey/otpravka-cheka-po-metodu-get + """ generated_pay_link: str = "{payme_url}/{encode_params}" params: str = 'm={payme_id};ac.{payme_account}={order_id};a={amount};c={call_back_url}' @@ -60,14 +74,80 @@ def generate_link(self) -> str: encode_params=str(encode_params, 'utf-8') ) + def __send_and_receive_data(self, data) -> typing.Union[str, None]: + # pylint: disable=missing-function-docstring + + message = None + + def on_message(ws: WebSocketApp, _message): + nonlocal message + message = _message + + ws.close() + + websocket.enableTrace(False) + + ws = WebSocketApp( + url="wss://checkout.paycom.uz/", + keep_running=False, + on_message=on_message + ) + + ws.on_open = lambda ws: ws.send(json.dumps(data)) + ws.run_forever(ping_interval=0.1) + + return message + + def to_qrcode(self, path: str = 'qr-codes', filename: str = None, **kwargs): + """ + Generate qr-code for order. + + Full method documentation + ---------- + https://developer.help.paycom.uz/initsializatsiya-platezhey/generatsiya-knopki-oplaty-i-qr-koda + + Parameters + ---------- + path: str -> output path (folder) name + filename: str -> output image name without suffix + lang: str -> user language. available values: ru, uz, en. + callback: str -> return url after payment or payment cancellation. + + Returns + ---------- + str -> path of qr code svg + """ + data = { + "lang": kwargs.get("lang", "ru"), + "merchant": PAYME_ID, + "amount": self.amount, + "account": {PAYME_ACCOUNT: self.order_id}, + "callback": kwargs.get("callback", PAYME_CALL_BACK_URL) + } + message = self.__send_and_receive_data(data) + + if message is None: + raise QRCodeError + + if not os.path.exists(path): + os.makedirs(path) + + image_name = uuid.uuid4().hex if not filename else filename + image_output_path = f'{path}/{image_name}.svg' + + with open(image_output_path, 'w', encoding='utf-8') as svg: + svg.write(message.split(',')[-1]) + + return image_output_path + @staticmethod - def to_tiyin(amount: Decimal) -> Decimal: + def to_tiyin(amount: int) -> int: """ Convert from sum to tiyin. Parameters ---------- - amount: Decimal -> order amount + amount: int -> order amount """ return amount * 100 @@ -78,6 +158,6 @@ def to_sum(amount: Decimal) -> Decimal: Parameters ---------- - amount: Decimal -> order amount + amount: int -> order amount """ return amount / 100 diff --git a/lib/payme/migrations/0001_initial.py b/lib/payme/migrations/0001_initial.py index e29aef7..883a477 100644 --- a/lib/payme/migrations/0001_initial.py +++ b/lib/payme/migrations/0001_initial.py @@ -1,4 +1,5 @@ # pylint: disable=invalid-name +import django.db.models.deletion from django.db import migrations, models @@ -9,40 +10,170 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='MerchantTransactionsModel', + name="FiscalData", fields=[ - ('id', models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID') - ), - ('_id', models.CharField(max_length=255, null=True)), - ('transaction_id', models.CharField(max_length=255, null=True)), - ('order_id', models.BigIntegerField(blank=True, null=True)), - ('amount', models.FloatField(blank=True, null=True)), - ('time', models.BigIntegerField(blank=True, null=True)), - ('perform_time', models.BigIntegerField(default=0, null=True)), - ('cancel_time', models.BigIntegerField(default=0, null=True)), - ('state', models.IntegerField(default=1, null=True)), - ('reason', models.CharField(blank=True, max_length=255, null=True)), - ('created_at_ms', models.CharField(blank=True, max_length=255, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("code", models.CharField(max_length=17)), + ("units", models.IntegerField(blank=True, null=True)), + ("package_code", models.CharField(max_length=255)), + ("vat_percent", models.IntegerField(blank=True, default=0, null=True)), ], + options={ + "verbose_name": "Fiscal Data", + "verbose_name_plural": "Fiscal Data", + }, ), migrations.CreateModel( - name='Order', + name="MerchantTransactionsModel", fields=[ - ('id', models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID') - ), - ('amount', models.IntegerField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("_id", models.CharField(max_length=255, null=True)), + ("transaction_id", models.CharField(max_length=255, null=True)), + ("order_id", models.BigIntegerField(blank=True, null=True)), + ("amount", models.BigIntegerField(blank=True, null=True)), + ("time", models.BigIntegerField(blank=True, null=True)), + ("perform_time", models.BigIntegerField(default=0, null=True)), + ("cancel_time", models.BigIntegerField(default=0, null=True)), + ("state", models.IntegerField(default=1, null=True)), + ("reason", models.CharField(blank=True, max_length=255, null=True)), + ( + "created_at_ms", + models.CharField(blank=True, max_length=255, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], + options={ + "verbose_name": "Merchant Transaction", + "verbose_name_plural": "Merchant Transactions", + }, + ), + migrations.CreateModel( + name="ShippingDetail", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("price", models.BigIntegerField(default=0)), + ], + options={ + "verbose_name": "Shipping Detail", + "verbose_name_plural": "Shipping Details", + }, + ), + migrations.CreateModel( + name="PaymeOrder", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("receipt_type", models.IntegerField(default=0)), + ( + "shipping", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="payme.shippingdetail", + ), + ), + ], + options={ + "verbose_name": "Payme Order", + "verbose_name_plural": "Payme Orders", + }, + ), + migrations.CreateModel( + name="PaymeItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("discount", models.BigIntegerField(default=0)), + ("title", models.CharField(max_length=255)), + ("price", models.BigIntegerField(default=0)), + ( + "fiscal_data", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="payme.fiscaldata", + ), + ), + ], + options={ + "verbose_name": "Payme Item", + "verbose_name_plural": "Payme Items", + }, + ), + migrations.CreateModel( + name="OrderItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("count", models.IntegerField(default=1)), + ( + "item", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="payme.paymeitem", + ), + ), + ( + "order", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="payme.paymeorder", + ), + ), + ], + options={ + "verbose_name": "Payme Order Item", + "verbose_name_plural": "Payme Order Items", + }, ), ] diff --git a/lib/payme/models.py b/lib/payme/models.py index 830917b..8027bc8 100644 --- a/lib/payme/models.py +++ b/lib/payme/models.py @@ -1,9 +1,4 @@ from django.db import models -from django.conf import settings -from django.utils.module_loading import import_string -from django.core.exceptions import FieldError - -from payme.utils.logging import logger class MerchantTransactionsModel(models.Model): @@ -14,7 +9,7 @@ class MerchantTransactionsModel(models.Model): _id = models.CharField(max_length=255, null=True, blank=False) transaction_id = models.CharField(max_length=255, null=True, blank=False) order_id = models.BigIntegerField(null=True, blank=True) - amount = models.FloatField(null=True, blank=True) + amount = models.BigIntegerField(null=True, blank=True) time = models.BigIntegerField(null=True, blank=True) perform_time = models.BigIntegerField(null=True, default=0) cancel_time = models.BigIntegerField(null=True, default=0) @@ -27,35 +22,158 @@ class MerchantTransactionsModel(models.Model): def __str__(self): return str(self._id) + class Meta: + # pylint: disable=missing-class-docstring + verbose_name = "Merchant Transaction" + verbose_name_plural = "Merchant Transactions" + + +class ShippingDetail(models.Model): + """ + ShippingDetail class \ + That's used for managing shipping + """ + title = models.CharField(max_length=255) + price = models.BigIntegerField(default=0) + + def __str__(self) -> str: + shipping_price = self.price / 100 # shipping price in soum + return f"[{self.pk}] {self.title} - {shipping_price:,}" + + class Meta: + # pylint: disable=missing-class-docstring + verbose_name = "Shipping Detail" + verbose_name_plural = "Shipping Details" + + +class FiscalData(models.Model): + """ + FiscalData class \ + That's used for managing fiscalization items + """ + code = models.CharField(max_length=17) + units = models.IntegerField(null=True, blank=True) + package_code = models.CharField(max_length=255) + vat_percent = models.IntegerField(default=0, null=True, blank=True) + + def __str__(self) -> str: + return f"[{self.pk}] {self.code} - {self.package_code}" + + class Meta: + # pylint: disable=missing-class-docstring + verbose_name = "Fiscal Data" + verbose_name_plural = "Fiscal Data" + + +class PaymeItem(models.Model): + """ + Item class \ + That's used for managing order items + """ + discount = models.BigIntegerField(default=0) + title = models.CharField(max_length=255) + price = models.BigIntegerField(default=0) + fiscal_data = models.ForeignKey( + FiscalData, null=True, + on_delete=models.SET_NULL + ) + + def __str__(self) -> str: + item_price = self.price / 100 # item price in soum + return f"[{self.pk}] {self.title} {item_price:,} UZS" + + class Meta: + # pylint: disable=missing-class-docstring + verbose_name = "Payme Item" + verbose_name_plural = "Payme Items" + + +class PaymeOrder(models.Model): + """ + Order class \ + That's used for managing order process + """ + receipt_type = models.IntegerField(default=0) + shipping = models.ForeignKey( + to=ShippingDetail, + null=True, blank=True, + on_delete=models.CASCADE + ) + + @property + def amount(self) -> int: + shipping_price = self.shipping.price if self.shipping else 0 + return int(shipping_price + self.cart_total) / 100 -try: - CUSTOM_ORDER = import_string(settings.ORDER_MODEL) + @property + def cart_total(self): + orderitems = self.orderitem_set.all() + total = sum(item.price for item in orderitems) + return total - if not isinstance(CUSTOM_ORDER, models.base.ModelBase): - raise TypeError("The input must be an instance of models.Model class") + @property + def cart_items(self): + orderitems = self.orderitem_set.all() + total_count = sum(item.count for item in orderitems) + return total_count - # pylint: disable=protected-access - if 'amount' not in [f.name for f in CUSTOM_ORDER._meta.fields]: - raise FieldError("Missing 'amount' field in your custom order model") + def to_detail(self): + orderitems = self.orderitem_set.all() + return { + "receipt_type": self.receipt_type, + "shipping": { + 'title': self.shipping.title, + 'price': self.shipping.price + } if self.shipping else None, + "items": [item.item_as_dict for item in orderitems] + } - Order = CUSTOM_ORDER -except (ImportError, AttributeError): - logger.warning("You have no payme custom order model") + def __str__(self): + return f"ORDER ID: {self.pk} - AMOUNT: {self.amount:,} UZS" + + class Meta: + # pylint: disable=missing-class-docstring + verbose_name = "Payme Order" + verbose_name_plural = "Payme Orders" + + +class OrderItem(models.Model): + """ + Order Item class \ + That's used for managing order items process + """ + item = models.ForeignKey(PaymeItem, on_delete=models.SET_NULL, null=True) + order = models.ForeignKey(PaymeOrder, on_delete=models.SET_NULL, null=True) + count = models.IntegerField(default=1) - CUSTOM_ORDER = None + @property + def price(self): + return (self.item.price * self.count) - (self.item.discount * self.count) - class Order(models.Model): - """ - Order class \ - That's used for managing order process - """ - amount = models.IntegerField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + @property + def item_as_dict(self): + fiscal_data = { + 'code': self.item.fiscal_data.code, + 'units': self.item.fiscal_data.units, + 'package_code': self.item.fiscal_data.package_code, + 'vat_percent': self.item.fiscal_data.vat_percent + } if self.item.fiscal_data else {} - def __str__(self): - return f"ORDER ID: {self.pk} - AMOUNT: {self.amount}" + return { + 'discount': self.item.discount, + 'title': self.item.title, + 'count': self.count, + 'price': self.item.price, + 'code': fiscal_data.get('code'), + 'units': fiscal_data.get('units'), + 'package_code': fiscal_data.get('package_code'), + 'vat_percent': fiscal_data.get('vat_percent') + } + + def __str__(self): + return f"ORDER ID: {self.order.pk} | ITEM: {self.item.title}" - class Meta: - # pylint: disable=missing-class-docstring - managed = False + class Meta: + # pylint: disable=missing-class-docstring + verbose_name = "Payme Order Item" + verbose_name_plural = "Payme Order Items" diff --git a/lib/payme/serializers.py b/lib/payme/serializers.py index e12d231..f944697 100644 --- a/lib/payme/serializers.py +++ b/lib/payme/serializers.py @@ -1,13 +1,15 @@ from django.conf import settings -from rest_framework import serializers - -from payme.models import Order -from payme.utils.logging import logger -from payme.utils.get_params import get_params +from payme.errors.exceptions import ( + IncorrectAmount, + PerformTransactionDoesNotExist +) from payme.models import MerchantTransactionsModel -from payme.errors.exceptions import IncorrectAmount -from payme.errors.exceptions import PerformTransactionDoesNotExist +from payme.utils.get_params import get_params +from payme.utils.logging import logger +from payme.models import PaymeOrder as Order + +from rest_framework import serializers class MerchantTransactionsModelSerializer(serializers.ModelSerializer): @@ -24,7 +26,7 @@ class Meta: fields: str = "__all__" extra_fields = ['start_date', 'end_date'] - def validate(self, attrs) -> dict: + def validate(self, attrs: dict) -> dict: """ Validate the data given to the MerchantTransactionsModel. """ @@ -47,12 +49,12 @@ def validate_amount(self, amount: int) -> int: Validator for Transactions Amount. """ if amount is not None: - if int(amount) <= int(settings.PAYME.get("PAYME_MIN_AMOUNT", 0)): + if amount <= int(settings.PAYME.get("PAYME_MIN_AMOUNT")): raise IncorrectAmount("Payment amount is less than allowed.") return amount - def validate_order_id(self, order_id) -> int: + def validate_order_id(self, order_id: int) -> int: """ Use this method to check if a transaction is allowed to be executed. diff --git a/lib/payme/utils/get_params.py b/lib/payme/utils/get_params.py index 8005c86..3649705 100644 --- a/lib/payme/utils/get_params.py +++ b/lib/payme/utils/get_params.py @@ -22,3 +22,19 @@ def get_params(params: dict) -> dict: clean_params["order_id"] = account[account_name] return clean_params + + +def clean_empty(data) -> dict: + """ + Use this function to clean the parameters from the instance. + """ + if isinstance(data, dict): + return { + k: v + for k, v in ((k, clean_empty(v)) for k, v in data.items()) + if v is not None and k != 'id' + } + if isinstance(data, list): + return [v for v in map(clean_empty, data) if v] + + return data diff --git a/lib/payme/views.py b/lib/payme/views.py index 189b421..5268c34 100644 --- a/lib/payme/views.py +++ b/lib/payme/views.py @@ -55,7 +55,9 @@ def post(self, request) -> Response: raise MethodNotFound() from error except PerformTransactionDoesNotExist as error: - logger.error("PerformTransactionDoesNotExist Error occurred: %s", error) + logger.error( + "PerformTransactionDoesNotExist Error occurred: %s", error + ) raise PerformTransactionDoesNotExist() from error order_id, action = paycom_method(incoming_data.get("params")) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 0739c50..c9370e5 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,3 +1,4 @@ requests==2.* dataclasses==0.* djangorestframework==3.14.0 +websocket-client==1.6.1 \ No newline at end of file diff --git a/setup.py b/setup.py index a8e1aec..993e76c 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='payme-pkg', - version='2.6.6', + version='3.0.1', license='MIT', author="Muhammadali Akbarov", author_email='muhammadali17abc@gmail.com', @@ -15,5 +15,6 @@ 'requests==2.*', "dataclasses==0.*;python_version<'3.7'", # will only install on py3.6 'djangorestframework==3.14.0' - ], + 'websocket-client==1.6.1' + ], )