From b8562b3d7b1e2e44e0cc35541f89a585b87f7b3b Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Fri, 22 Mar 2024 00:44:49 +0100 Subject: [PATCH] feat: added tpv app --- config/api/v1/urls.py | 4 +- config/api/v1/views.py | 1 - config/settings/base.py | 19 +- config/urls.py | 1 + pytest.ini | 3 + requirements.txt | 4 +- tpv/__init__.py | 0 tpv/admin.py | 12 ++ tpv/apps.py | 6 + tpv/exceptions.py | 6 + tpv/forms.py | 14 ++ tpv/migrations/0001_initial.py | 99 +++++++++++ tpv/migrations/__init__.py | 0 tpv/models.py | 75 ++++++++ tpv/redsys.py | 105 +++++++++++ tpv/settings.py | 254 +++++++++++++++++++++++++++ tpv/signals.py | 5 + tpv/templates/tpv/base.html | 38 ++++ tpv/templates/tpv/cart_item.html | 15 ++ tpv/templates/tpv/header.html | 3 + tpv/templates/tpv/order_created.html | 102 +++++++++++ tpv/tests.py | 3 + tpv/tests/__init__.py | 0 tpv/tests/test_redsys.py | 169 ++++++++++++++++++ tpv/urls.py | 12 ++ tpv/utils.py | 99 +++++++++++ tpv/views.py | 111 ++++++++++++ 27 files changed, 1153 insertions(+), 7 deletions(-) create mode 100644 pytest.ini create mode 100644 tpv/__init__.py create mode 100644 tpv/admin.py create mode 100644 tpv/apps.py create mode 100644 tpv/exceptions.py create mode 100644 tpv/forms.py create mode 100644 tpv/migrations/0001_initial.py create mode 100644 tpv/migrations/__init__.py create mode 100644 tpv/models.py create mode 100644 tpv/redsys.py create mode 100644 tpv/settings.py create mode 100644 tpv/signals.py create mode 100644 tpv/templates/tpv/base.html create mode 100644 tpv/templates/tpv/cart_item.html create mode 100644 tpv/templates/tpv/header.html create mode 100644 tpv/templates/tpv/order_created.html create mode 100644 tpv/tests.py create mode 100644 tpv/tests/__init__.py create mode 100644 tpv/tests/test_redsys.py create mode 100644 tpv/urls.py create mode 100644 tpv/utils.py create mode 100644 tpv/views.py diff --git a/config/api/v1/urls.py b/config/api/v1/urls.py index 31f406b..fdf8282 100644 --- a/config/api/v1/urls.py +++ b/config/api/v1/urls.py @@ -5,6 +5,8 @@ from config.api.v1.views import APISchema urlpatterns = [ path("schema/", APISchema.as_view(), name="schema"), - path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path( + "swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui" + ), path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] diff --git a/config/api/v1/views.py b/config/api/v1/views.py index c6819ce..0ca967f 100644 --- a/config/api/v1/views.py +++ b/config/api/v1/views.py @@ -3,4 +3,3 @@ from drf_spectacular.views import SpectacularAPIView class APISchema(SpectacularAPIView): api_version = "v1" - diff --git a/config/settings/base.py b/config/settings/base.py index 5da7898..697007b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,9 +1,9 @@ import datetime from config.settings.environ import * # noqa -APP_NAME = 'template' -DESCRIPTION = '' -VERSION = '0.1.0' +APP_NAME = "TPV" +DESCRIPTION = "" +VERSION = "0.1.0" DJANGO_APPS = [ @@ -26,7 +26,7 @@ THIRD_PARTY_APPS = [ ] PROJECT_APPS = [ - + "tpv", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS @@ -176,3 +176,14 @@ SPECTACULAR_SETTINGS = { CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", default="redis://172.17.0.1:6379/0") CELERY_TIME_ZONE = TIME_ZONE CELERY_ALWAYS_EAGER = DEBUG + +# Redsys +# https://pagosonline.redsys.es/conexion-redireccion.html + +REDSYS_SHARED_SECRET = env.str( + "REDSYS_SHARED_SECRET", "sq7HjrUOBfKmC576ILgskD5srU870gJ7" +) +REDSYS_MERCHANT_CODE = env.str("REDSYS_MERCHANT_CODE", "999008881") +REDSYS_TERMINAL = env.str("REDSYS_TERMINAL", "001") +REDSYS_CURRENCY_CODE = env.str("REDSYS_CURRENCY_CODE", "978") +REDSYS_TPV_DOMAIN = env.str("REDSYS_TPV_DOMAIN", "") diff --git a/config/urls.py b/config/urls.py index 28b7917..a72fc89 100644 --- a/config/urls.py +++ b/config/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/v1/", include("config.api.v1.urls")), path("watchman", include("watchman.urls")), + path("tpv/", include("tpv.urls", namespace="tpv")), ] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7522288 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings.develop +addopts = --ignore=src diff --git a/requirements.txt b/requirements.txt index ddbb97b..e00f58e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ celery==5.2.7 -django==5.0.1 +django==5.0.3 django-cors-headers==3.13.0 +django-cryptography==1.1 django-extensions==3.2.0 django-filter==23.1 django-environ==0.10.0 @@ -13,6 +14,7 @@ drf-spectacular==0.26.1 ipython==8.16.1 Pillow==10.0.1 psycopg2-binary==2.9.5 +pyDes==2.0.1 redis==4.5.4 requests==2.31.0 uvicorn==0.21.1 diff --git a/tpv/__init__.py b/tpv/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tpv/admin.py b/tpv/admin.py new file mode 100644 index 0000000..09315d9 --- /dev/null +++ b/tpv/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from tpv.models import PaymentTPV +from unfold.admin import ModelAdmin + + +# Register your models here. +@admin.register(PaymentTPV) +class PaymentTPVAdmin(ModelAdmin): + list_display = ( + "hash", + "amount", + ) diff --git a/tpv/apps.py b/tpv/apps.py new file mode 100644 index 0000000..f1171a4 --- /dev/null +++ b/tpv/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TpvConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tpv" diff --git a/tpv/exceptions.py b/tpv/exceptions.py new file mode 100644 index 0000000..ab5b9c3 --- /dev/null +++ b/tpv/exceptions.py @@ -0,0 +1,6 @@ +class RedsysValidationException(Exception): + pass + + +class RedsysPaymentException(Exception): + pass diff --git a/tpv/forms.py b/tpv/forms.py new file mode 100644 index 0000000..0796af9 --- /dev/null +++ b/tpv/forms.py @@ -0,0 +1,14 @@ +from django import forms +from django.utils.text import gettext_lazy as _ + + +class UpdateEmailForm(forms.Form): + contact_email = forms.EmailField( + label=_("Introduce tu e-mail"), + required=True, + widget=forms.EmailInput( + attrs={ + "class": "peer bg-green-50 border border-green-500 text-green-900 light:text-green-400 placeholder-green-700 light:placeholder-green-500 text-sm rounded-lg focus:ring-green-500 focus:border-green-500 block w-full p-2.5 light:bg-gray-700 light:border-green-500" + } + ), + ) diff --git a/tpv/migrations/0001_initial.py b/tpv/migrations/0001_initial.py new file mode 100644 index 0000000..3a320e1 --- /dev/null +++ b/tpv/migrations/0001_initial.py @@ -0,0 +1,99 @@ +# Generated by Django 5.0.3 on 2024-03-21 22:43 + +import tpv.models +import uuid +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="PaymentTPV", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "hash", + models.UUIDField( + db_index=True, default=uuid.uuid4, verbose_name="Hash" + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PAI", "Pagado"), + ("PEN", "Pendiente"), + ("ERR", "Error"), + ], + default="PEN", + max_length=3, + verbose_name="Estado", + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=8, + verbose_name="Cantidad a pagar", + ), + ), + ( + "creation_date", + models.DateTimeField( + auto_now_add=True, verbose_name="Fecha de creación" + ), + ), + ( + "last_modification_date", + models.DateTimeField( + auto_now=True, verbose_name="Fecha de última modificación" + ), + ), + ( + "contact_email", + models.EmailField( + blank=True, + max_length=254, + null=True, + verbose_name="Email de contacto", + ), + ), + ( + "observations", + models.CharField( + blank=True, + default="", + max_length=255, + null=True, + verbose_name="Observaciones", + ), + ), + ( + "metadata", + models.JSONField( + default=tpv.models.set_default_metadata, verbose_name="Metadata" + ), + ), + ], + options={ + "verbose_name": "Pago de pedido en Redsys", + "verbose_name_plural": "Pagos de pedidos en Redsys", + }, + ), + ] diff --git a/tpv/migrations/__init__.py b/tpv/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tpv/models.py b/tpv/models.py new file mode 100644 index 0000000..bd991b7 --- /dev/null +++ b/tpv/models.py @@ -0,0 +1,75 @@ +from decimal import Decimal +from uuid import uuid4 +from django.db import models +from django.utils.text import gettext_lazy as _ + + +def set_default_metadata(): + return {"items": [], "response": None} + + +class PaymentTPV(models.Model): + class StatusChoices(models.TextChoices): + PAID = "PAI", _("Pagado") + PENDING = "PEN", _("Pendiente") + ERROR = "ERR", _("Error") + + hash = models.UUIDField(verbose_name=_("Hash"), default=uuid4, db_index=True) + + status = models.CharField( + max_length=3, + default=StatusChoices.PENDING, + choices=StatusChoices.choices, + verbose_name=_("Estado"), + ) + amount = models.DecimalField( + max_digits=8, + decimal_places=2, + default=Decimal("0"), + verbose_name=_("Cantidad a pagar"), + ) + + creation_date = models.DateTimeField( + auto_now_add=True, verbose_name=_("Fecha de creación") + ) + last_modification_date = models.DateTimeField( + auto_now=True, verbose_name=_("Fecha de última modificación") + ) + + contact_email = models.EmailField( + blank=True, null=True, verbose_name=_("Email de contacto") + ) + observations = models.CharField( + max_length=255, + default="", + blank=True, + null=True, + verbose_name=_("Observaciones"), + ) + + metadata = models.JSONField( + default=set_default_metadata, verbose_name=_("Metadata") + ) + """ + Metadata example: + { + "items": [{ + "id": 1000, + "type": "type", + "amount_to_pay": 10.00, + "amount_to_pay_int": 1000, + }], + } + """ + + def regenerate_uuid(self): + self.hash = uuid4() + self.save() + + @property + def amount_integer(self) -> int: + return int(self.amount * 100) + + class Meta: + verbose_name = _("Pago de pedido en Redsys") + verbose_name_plural = _("Pagos de pedidos en Redsys") diff --git a/tpv/redsys.py b/tpv/redsys.py new file mode 100644 index 0000000..06efbe8 --- /dev/null +++ b/tpv/redsys.py @@ -0,0 +1,105 @@ +import base64 +import json + +from django.conf import settings +from django.urls import reverse +from tpv.models import PaymentTPV +from tpv.utils import compute_signature + + +class TransactionTypes: + AUTHORIZATION = 0 + PREAUTHORIZATION = 1 + REPLACEMENT_PREAUTHORIZATION = 11 + CONFIRMATION = 2 + REFUND = 3 + SEPARATED_CONFIRMATION = 8 + CANCELED = 9 + PAYGOLD = 15 + PUCE_AUTHENTICATION = 17 + REFUND_WITHOUT_ORIGINAL = 34 + BET_PRICE = 37 + PAYMENT_CANCELED = 45 + REFUND_CANCELED = 46 + SEPARATED_CONFIRMATION_CANCELED = 47 + LINK_EXPIRATION_CHANGED_FOR_PAYGOLD = 51 + + +class RedsysClient: + DEBUG_ENVIRONMENT_URL = "https://sis-t.redsys.es:25443/sis/realizarPago" + PROD_ENVIRONMENT_URL = "" + + def get_target_url(self): + if settings.DEBUG: + return self.DEBUG_ENVIRONMENT_URL + return self.PROD_ENVIRONMENT_URL + + def get_merchant_code(self) -> str: + return settings.REDSYS_MERCHANT_CODE + + def get_terminal(self) -> str: + return settings.REDSYS_TERMINAL + + def get_currency_code(self) -> str: + return settings.REDSYS_CURRENCY_CODE + + def get_merchant_url_ok_for_order(self, order: PaymentTPV) -> str: + path = reverse("tpv:ok", kwargs={"order": order.hash}) + return f"{settings.REDSYS_TPV_DOMAIN}{path}" + + def get_merchant_url_ko_for_order(self, order: PaymentTPV) -> str: + path = reverse("tpv:ko", kwargs={"order": order.hash}) + return f"{settings.REDSYS_TPV_DOMAIN}{path}" + + def get_webhook_url_for_order(self, order: PaymentTPV) -> str: + path = reverse("tpv:webhook", kwargs={"order": order.hash}) + return f"{settings.REDSYS_TPV_DOMAIN}{path}" + + def get_shared_secret(self) -> str: + return settings.REDSYS_SHARED_SECRET + + def get_signature(self, hash, payload: str): + key = self.get_shared_secret() + return compute_signature(str(hash), payload, key).decode() + + def get_merchant_parameters_for_order( + self, order: PaymentTPV, transaction_type: int = TransactionTypes.AUTHORIZATION + ) -> dict: + merchant_code = self.get_merchant_code() + + return { + "DS_MERCHANT_AMOUNT": str(order.amount_integer), + "DS_MERCHANT_CURRENCY": self.get_currency_code(), + "DS_MERCHANT_MERCHANTCODE": merchant_code, + "DS_MERCHANT_MERCHANTURL": self.get_webhook_url_for_order(order), # Webhook + "DS_MERCHANT_ORDER": order.hash.hex, + "DS_MERCHANT_TERMINAL": self.get_terminal(), + "DS_MERCHANT_TRANSACTIONTYPE": transaction_type, + "DS_MERCHANT_URLKO": self.get_merchant_url_ko_for_order( + order + ), # Página informativa al usuario - Pago erróneo + "DS_MERCHANT_URLOK": self.get_merchant_url_ok_for_order( + order + ), # Página informativa al usuario - Pago confirmado + } + + def get_encoded_merchant_parameters_for_order( + self, order: PaymentTPV, transaction_type: int = TransactionTypes.AUTHORIZATION + ) -> str: + body = self.get_merchant_parameters_for_order(order, transaction_type) + stringified_body = json.dumps(body) + return base64.b64encode(stringified_body.encode()).decode("utf-8") + + def get_body_for_order( + self, order: PaymentTPV, transaction_type: int = TransactionTypes.AUTHORIZATION + ) -> dict: + merchant_parameters = self.get_encoded_merchant_parameters_for_order( + order, transaction_type + ) + signature = self.get_signature(order.hash.hex, merchant_parameters) + + return { + "Ds_MerchantParameters": merchant_parameters, + "Ds_SignatureVersion": "HMAC_SHA256_V1", + "Ds_Signature": signature, + } diff --git a/tpv/settings.py b/tpv/settings.py new file mode 100644 index 0000000..0b8dbda --- /dev/null +++ b/tpv/settings.py @@ -0,0 +1,254 @@ +from django.utils.text import gettext_lazy as _ + + +CURRENCY_CODES = { + 8: "LEK ALL", + 12: "ALGERIAN DINAR DZD", + 24: "ANGOLA KWANZA AOK", + 30: "PROBANDO DESA MON", + 31: "AZERBAIJANIAN MANAT AZM", + 32: "ARGENTINE AUSTRAL ARP", + 36: "AUSTRALIAN DOLLAR AUD", + 44: "BAHAMIAN DOLLAR BSD", + 48: "BAHRAINI DINAR BHD", + 50: "TAKA BDT", + 51: "ARMENIAN DRAM AMD", + 52: "BARBADOS DOLLAR BBD", + 60: "BERMUDAN DOLLAR BMD", + 64: "NGULTRUM BTN", + 68: "BOLIVIAN PESO BOP", + 70: "DINAR BAD", + 72: "PULA BWP", + 76: "CRUZEIRO BRC", + 84: "BELIZE DOLLAR 084", + 90: "SOLOMON ISLANDS DOLL SBD", + 96: "BRUNEI DOLLAR BND", + 100: "LEV BGL", + 104: "KYAT BUK", + 108: "BURUNDI FRANC BIF", + 112: "BELARUSSIAN RUBLE BYB", + 116: "RIEL KHR", + 124: "CANADIAN DOLLAR CAD", + 132: "CAPE VERDE ESCUDO CVE", + 136: "CAYMAN ISLANDS DOLLA KYD", + 144: "SRI LANKA RUPEE LKR", + 152: "CHILEAN PESO CLP", + 156: "YUAN RENMINBI CNY", + 157: "CHINESE RENMIMBI CNH", + 158: "CHINESE RENMINBI CNX", + 170: "COLOMBIAN PESO COP", + 174: "COMOROS FRANC KMF", + 180: "ZAIRE ZRZ", + 188: "COSTA RICA COLON CRC", + 191: "CROATIAN KUNA HRK", + 192: "CUBAN PESO CUP", + 196: "CYPRUS POUND CYP", + 200: "KORUNA CSK", + 203: "CZECH KORUNA CZK", + 208: "DANISH KRONE DKK", + 214: "DOMINICAN PESO DOP", + 218: "SUCRE ECS", + 222: "EL SALVADOR COLON SVC", + 226: "EKWELE GQE", + 230: "ETHIOPIAN BIRR ETB", + 232: "ERITREAN NAKTAN ERN", + 233: "ESTONIAN KROON EEK", + 238: "FALKLAND ISLANDS FKP", + 242: "FIJI DOLLAR FJD", + 262: "DJIBOUTI FRANC DJF", + 268: "GEORGIAN LARI GEL", + 270: "DALASI GMD", + 278: "MARK DER DDR DDM", + 288: "GHANA CEDI GHC", + 292: "GIBRALTAR POUND GIP", + 320: "QUETZAL GTQ", + 324: "SYLI GNS", + 328: "GUYANA DOLLAR GYD", + 332: "GOURDE HTG", + 340: "LEMPIRA HNL", + 344: "HONG KONG DOLLAR HKD", + 348: "FORINT HUF", + 352: "ICELAND KRONA ISK", + 356: "INDIAN RUPEE INR", + 360: "RUPIAH IDR", + 364: "IRANIAL RIAL IRR", + 365: "IRANIAN AIRLINE RATE IRA", + 368: "IRAQI DINAR IQD", + 376: "ISRAEL SHEKEL ILS", + 388: "JAMAICAN DOLLAR JMD", + 392: "YEN JPY", + 398: "TENGE KZT", + 400: "JORDANIAN DINAR JOD", + 404: "KENYAN SHILLING KES", + 408: "NORTH KOREAN WON KPW", + 410: "KOREAN WON KRW", + 414: "KUWAITI DINAR KWD", + 417: "KYRGYZSTAN SON KGS", + 418: "KIP LAK", + 422: "LEBANESE POUND LBP", + 426: "LESOTHO LOTI LSM", + 428: "LATVIAN LAT LVL", + 430: "LIBERIAN DOLLAR LRD", + 434: "LIBYAN DINAR LYD", + 440: "LITHUANIAN LITAS LTL", + 446: "PATACA MOP", + 450: "MALAGASY FRANC MGF", + 454: "MALAWI KWACHA MWK", + 458: "MALASYAN RINGGIT MYR", + 462: "MALDIVE RUPEE MVR", + 466: "MALI MLF", + 470: "MALTESE LIRA MTL", + 478: "OUGUIYA MRO", + 480: "MAURITIUS RUPEE MUR", + 484: "MEXICAN PESO MXP", + 496: "TUGRIK MNT", + 498: "MOLDOVIAN LEU MDL", + 504: "MORROCAN DIRHAM MAD", + 508: "METICAL MZM", + 512: "RIAL OMANI OMR", + 516: "NAMIBIAN DOLLAR NAD", + 524: "NEPALESE RUPEE NPR", + 532: "NETHERLANDS ANTILLIA ANG", + 533: "ARUBA AWG", + 536: "YUGOSLAVIAN NEW DIAN NTZ", + 548: "VANUATU VATU VUV", + 554: "NEW ZEALAND DOLLAR NZD", + 556: "NAIRA 566", + 558: "CORDOBA NIC", + 566: "NAIRA NGN", + 578: "NORWEGIAN KRONE NOK", + 582: "PACIFIC ISLAND PCI", + 586: "PAKISTAN RUPEE PKR", + 590: "BALBOA PAB", + 598: "KINA PGK", + 600: "GUARANI PYG", + 604: "PERU INTI PEI", + 608: "PHILIPPINE PESO PHP", + 616: "ZLOTY PLZ", + 624: "GUINEA", + 626: "TIMOR ESCUDO TPE", + 634: "QATARI RIAL QAR", + 642: "LEU ROL", + 643: "RUSSIAN ROUBLE RUB", + 646: "RWANDA FRANC RWF", + 654: "ST.HELENA POUND SHP", + 678: "DOBRA STD", + 682: "SAUDI RIYAL SAR", + 690: "SEYCHELLES RUPEE SCR", + 694: "LEONE SLL", + 702: "SINGAPORE DOLLAR SGD", + 703: "SLOVAK KORUNA SKK", + 704: "DONG VND", + 705: "SLOVENIAN TOLAR SIT", + 706: "SOMALI SHILLING SOS", + 710: "RAND ZAR", + 716: "ZIMBABWE DOLLAR ZWD", + 720: "YEMENI DINAR YDD", + 728: "SOUTH SUDANESE POUND SSP", + 736: "SUDANESE POUND SDP", + 737: "SUDAN AIRLINES SDA", + 740: "SURINAM GUILDER SRG", + 748: "LILANGENI SZL", + 752: "SWEDISH KRONA SEK", + 756: "SWISS FRANC CHF", + 760: "SYRIAN POUND SYP", + 762: "TAJIK RUBLE TJR", + 764: "BAHT THB", + 776: "PA'ANGA TOP", + 780: "TRINIDAD Y TOBAGO DO TTD", + 784: "UAE DIRHAM AED", + 788: "TUNISIAN DINAR TND", + 792: "TURKISH LIRA TRL", + 793: "PSEUDO TURKISH LIRA PTL", + 795: "MANAT TMM", + 800: "UGANDA SHILLING UGS", + 804: "KARBOVANET UAK", + 807: "MACEDONIAN DENAR MKD", + 810: "RUSSIAN ROUBLE RUR", + 818: "EGYPTIAN POUND EGP", + 826: "POUND STERLING GBP", + 834: "TANZANIAN SHILLING TZS", + 840: "DOLAR U.S.A. USD", + 858: "URUGUAYAN PESO UYP", + 860: "UZBEKISTAN SUM UZS", + 862: "BOLIVAR VEB", + 882: "TALA WST", + 886: "YEMINI RIAL YER", + 890: "NEW YUGOSLAVIAN DOLL YUD", + 891: "NEW DINAR YUG", + 894: "KWACHA ZMK", + 901: "NEW TAIWAN DOLLAR TWD", + 934: "NEW MANAT TMT", + 936: "GHANA CEDI GHS", + 941: "DINAR SERBIO RSD", + 943: "MOZAMBIQUE METICAL MZN", + 944: "AZERBAIJANIAN MANAT AZN", + 946: "NEW LEU RON", + 949: "TURKISH LIRA TRY", + 950: "CFA FRANC BEAC XAF", + 951: "EAST CARIBBEAN DOLLA XCD", + 952: "CFA FRANC BCEAO XOF", + 953: "CFP FRANC XPF", + 954: "E.C.U. EUROPEAN CUR XEU", + 967: "KWACHA ZMW", + 968: "SURINAME DOLLAR SRD", + 969: "ARIARY MGA", + 971: "AFGHANISTAN AFGHANI AFN", + 972: "TAJIKISTAN SOMONI TJS", + 973: "KWANZA ANGOLA AOA", + 974: "BELARUSSIAN RUBLE BYR", + 975: "NEW LEV BGN", + 976: "FRANCO DEL CONGO CDF", + 977: "BOSNIAN MARKA BAM", + 978: "EURO EUR", + 980: "HRYVNIA UAH", + 981: "GEORGIAN LARI GEL", + 985: "NEW POLISH ZLOTY PLN", + 986: "BRAZILIAN REAL BRL", + 991: "RAND FINANCIER ZAL", +} + +ERROR_CODES = { + "10000": _("Error no tipificado"), + "0900": _("Transacción autorizada para devoluciones y confirmaciones"), + "0400": _("Transacción autorizada para anulaciones"), + "0101": _("Tarjeta caducada"), + "0102": _("Tarjeta en excepción transitoria o bajo sospecha de fraude"), + "0106": _("Intentos de PIN excedidos"), + "0125": _("Tarjeta no efectiva"), + "0129": _("Código de seguridad (CVV2/CVC2) incorrecto"), + "172": _("Denegada, no repetir."), + "173": _("Denegada, no repetir sin actualizar datos de tarjeta."), + "174": _("Denegada, no repetir antes de 72 horas."), + "0180": _("Tarjeta ajena al servicio"), + "0184": _("Error en la autenticación del titular"), + "0190": _("Denegación del emisor sin especificar motivo"), + "0191": _("Fecha de caducidad errónea"), + "0195": _("Requiere autenticación SCA"), + "0202": _( + "Tarjeta en excepción transitoria o bajo sospecha de fraude con retirada de tarjeta" + ), + "0904": _("Comercio no registrado en FUC"), + "0909": _("Error de sistema"), + "0913": _("Pedido repetido"), + "0944": _("Sesión Incorrecta"), + "0950": _("Operación de devolución no permitida"), + "9912": _("Emisor no disponible"), + "0912": _("Emisor no disponible"), + "9064": _("Número de posiciones de la tarjeta incorrecto"), + "9078": _("Tipo de operación no permitida para esa tarjeta"), + "9093": _("Tarjeta no existente"), + "9094": _("Rechazo servidores internacionales"), + "9104": _("Comercio con titular seguro y titular sin clave de compra segura"), + "9218": _("El comercio no permite op. seguras por entrada /operaciones"), + "9253": _("Tarjeta no cumple el check-digit"), + "9256": _("El comercio no puede realizar preautorizaciones"), + "9257": _("Esta tarjeta no permite operativa de preautorizaciones"), + "9261": _( + "Operación detenida por superar el control de restricciones en la entrada al SIS" + ), + "9915": _("A petición del usuario se ha cancelado el pago"), + "9997": _("Se está procesando otra transacción en SIS con la misma tarjeta"), + "9998": _("Operación en proceso de solicitud de datos de tarjeta"), + "9999": _("Operación que ha sido redirigida al emisor a autenticar"), +} diff --git a/tpv/signals.py b/tpv/signals.py new file mode 100644 index 0000000..b968008 --- /dev/null +++ b/tpv/signals.py @@ -0,0 +1,5 @@ +from django.dispatch import Signal + + +redsys_payment_accepted = Signal() +redsys_payment_rejected = Signal() diff --git a/tpv/templates/tpv/base.html b/tpv/templates/tpv/base.html new file mode 100644 index 0000000..15733e9 --- /dev/null +++ b/tpv/templates/tpv/base.html @@ -0,0 +1,38 @@ + + + + + Pago de facturas + + + + + + + + +{% include 'tpv/header.html' %} + +{% block body %} +{% endblock %} + + + \ No newline at end of file diff --git a/tpv/templates/tpv/cart_item.html b/tpv/templates/tpv/cart_item.html new file mode 100644 index 0000000..108c24f --- /dev/null +++ b/tpv/templates/tpv/cart_item.html @@ -0,0 +1,15 @@ +
+
+
+

Factura #{{ item.id }}

+

+ {{ item.name }} - {{ item.description }} +

+
+
+
+ {{ item.amount_to_pay }} € +
+
+
+
diff --git a/tpv/templates/tpv/header.html b/tpv/templates/tpv/header.html new file mode 100644 index 0000000..d6a2c9d --- /dev/null +++ b/tpv/templates/tpv/header.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/tpv/templates/tpv/order_created.html b/tpv/templates/tpv/order_created.html new file mode 100644 index 0000000..b85a5df --- /dev/null +++ b/tpv/templates/tpv/order_created.html @@ -0,0 +1,102 @@ +{% extends 'tpv/base.html' %} + +{% block body %} + +
+ +
+

Resumen de pedido

+ + {% if order.status == 'PAI' %} + Pagado + {% elif order.status == 'PEN' %} + Pendiente de pago + {% else %} + Error de pago + {% endif %} + +
+ +
+
+ + {% for item in order.metadata.items %} + {% include 'tpv/cart_item.html' %} + {% endfor %} + +
+ +
+ {% if order.status == 'PEN' %} + + +
+
+
+
+
+ {% csrf_token %} + + {% for field in form %} + + {{ field }} + + {% endfor %} + + +
+ +
+
+
+
+ + {% endif %} + +
+
+

Subtotal

+

{{ order.amount }} €

+
+ +
+
+

Total

+
+

{{ order.amount }} €

+

Impuestos incluidos

+
+
+ + {% if order.status == 'PEN' %} +
+ {% csrf_token %} + + + + + {% if order.contact_email != '' %} + + {% else %} + + {% endif %} +
+ {% endif %} +
+ + +
+
+ +
+{% endblock %} diff --git a/tpv/tests.py b/tpv/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tpv/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tpv/tests/__init__.py b/tpv/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tpv/tests/test_redsys.py b/tpv/tests/test_redsys.py new file mode 100644 index 0000000..a372413 --- /dev/null +++ b/tpv/tests/test_redsys.py @@ -0,0 +1,169 @@ +import base64 +import json +from decimal import Decimal +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from django.conf import settings + +from tpv.models import PaymentTPV +from tpv.redsys import RedsysClient +from tpv.settings import ERROR_CODES + + +class TestRedsysTPV(APITestCase): + def test_redsys_client(self): + amount_to_pay = Decimal("10.00") + order = PaymentTPV.objects.create(amount=amount_to_pay) + + items = [ + { + "id": 1, + "name": "Papacolas", + "description": "Las mejores papacolas", + "type": "INV", + "amount_to_pay": str(amount_to_pay), + } + ] + + order.metadata["items"] = items + order.save() + + client = RedsysClient() + merchant_parameters = client.get_merchant_parameters_for_order(order) + + assert merchant_parameters.get("DS_MERCHANT_ORDER") == order.hash.hex + assert merchant_parameters.get("DS_MERCHANT_AMOUNT") == str( + int(amount_to_pay) * 100 + ) + assert ( + merchant_parameters.get("DS_MERCHANT_TERMINAL") == settings.REDSYS_TERMINAL + ) + assert ( + merchant_parameters.get("DS_MERCHANT_MERCHANTCODE") + == settings.REDSYS_MERCHANT_CODE + ) + + client.get_body_for_order(order) + + def test_redsys_webhook(self): + amount_to_pay = Decimal("10.00") + order = PaymentTPV.objects.create(amount=amount_to_pay) + + items = [ + { + "id": 1, + "name": "Papacolas", + "description": "Las mejores papacolas", + "type": "INV", + "amount_to_pay": str(amount_to_pay), + } + ] + order.metadata["items"] = items + order.save() + + response = self.client.get( + reverse("tpv:order_created", kwargs={"order": order.hash}) + ) + assert response.status_code == status.HTTP_200_OK + + redsys_response_data = { + "Ds_MerchantCode": "999008881", + "Ds_Terminal": "001", + "Ds_Order": order.hash.hex, + "Ds_Amount": str(order.amount_integer), + "Ds_Currency": "978", + "Ds_Date": "01/01/2024", + "Ds_Hour": "00:00", + "Ds_SecurePayment": "1", + "Ds_Card_Number": "454881******1156", + "Ds_Card_Country": "724", + "Ds_Response": "0000", + "Ds_MerchantData": "", + "Ds_TransactionType": "0", + "Ds_ConsumerLanguage": "1", + "Ds_AuthorisationCode": "182670", + "Ds_Card_Brand": "1", + "Ds_ProcessedPayMethod": "80", + "Ds_ECI": "05", + "Ds_Response_Description": "OPERACION AUTORIZADA", + } + redsys_response_data_str = json.dumps(redsys_response_data) + b64_merchant_params = base64.b64encode( + redsys_response_data_str.encode() + ).decode() + + response = self.client.post( + reverse("tpv:webhook", kwargs={"order": order.hash}), + data={ + "Ds_MerchantParameters": b64_merchant_params, + "Ds_Signature": settings.REDSYS_SHARED_SECRET, + "Ds_SignatureVersion": "HMAC_SHA256_V1", + }, + ) + + assert response.status_code == status.HTTP_200_OK + + def test_redsys_webhook_payment_error(self): + amount_to_pay = Decimal("10.00") + order = PaymentTPV.objects.create(amount=amount_to_pay) + + items = [ + { + "id": 1, + "name": "Papacolas", + "description": "Las mejores papacolas", + "type": "INV", + "amount_to_pay": str(amount_to_pay), + } + ] + + order.metadata["items"] = items + + order.save() + + response = self.client.get( + reverse("tpv:order_created", kwargs={"order": order.hash}) + ) + assert response.status_code == status.HTTP_200_OK + + redsys_response_data = { + "Ds_MerchantCode": "999008881", + "Ds_Terminal": "001", + "Ds_Order": order.hash.hex, + "Ds_Amount": "1000", + "Ds_Currency": "978", + "Ds_Date": "01/01/2024", + "Ds_Hour": "00:00", + "Ds_SecurePayment": "1", + "Ds_Card_Number": "454881******1156", + "Ds_Card_Country": "724", + "Ds_Response": "0184", + "Ds_MerchantData": "", + "Ds_TransactionType": "0", + "Ds_ConsumerLanguage": "1", + "Ds_AuthorisationCode": "182670", + "Ds_Card_Brand": "1", + "Ds_ProcessedPayMethod": "80", + "Ds_ECI": "05", + "Ds_Response_Description": "OPERACION AUTORIZADA", + } + + redsys_response_data_str = json.dumps(redsys_response_data) + b64_merchant_params = base64.b64encode( + redsys_response_data_str.encode() + ).decode() + + response = self.client.post( + reverse("tpv:webhook", kwargs={"order": order.hash}), + data={ + "Ds_MerchantParameters": b64_merchant_params, + "Ds_Signature": settings.REDSYS_SHARED_SECRET, + "Ds_SignatureVersion": "HMAC_SHA256_V1", + }, + ) + + assert response.status_code == status.HTTP_409_CONFLICT + order.refresh_from_db() + assert str(ERROR_CODES.get("0184")) in order.observations + assert order.status == PaymentTPV.StatusChoices.ERROR diff --git a/tpv/urls.py b/tpv/urls.py new file mode 100644 index 0000000..5b02f32 --- /dev/null +++ b/tpv/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from tpv.views import order_created, webhook, payment_accepted, payment_rejected + +app_name = "tpv" + + +urlpatterns = [ + path("order//", order_created, name="order_created"), + path("order//webhook/", webhook, name="webhook"), + path("order//ok/", payment_accepted, name="ok"), + path("order//ko/", payment_rejected, name="ko"), +] diff --git a/tpv/utils.py b/tpv/utils.py new file mode 100644 index 0000000..140be50 --- /dev/null +++ b/tpv/utils.py @@ -0,0 +1,99 @@ +import base64 +import hashlib +import hmac +import json + +import pyDes +import re + +from decimal import Decimal +from django.utils.text import gettext_lazy as _ + +from tpv.exceptions import RedsysValidationException, RedsysPaymentException +from tpv.models import PaymentTPV +from tpv.settings import ERROR_CODES + + +def compute_signature(salt, payload, key): + """ + :param salt: order number (Ds_Order or Ds_Merchant_Order) + :param payload: Ds_MerchantParameters + :param key: shared secret (aka key) from the Redsys Administration Module + :return: + """ + b64_key = base64.b64decode(key) + + des3 = pyDes.triple_des( + b64_key, mode=pyDes.CBC, IV="\0" * 8, pad="\0", padmode=pyDes.PAD_NORMAL + ) + pepper = des3.encrypt(str(salt)) + + payload_hash = hmac.new(pepper, payload.encode(), hashlib.sha256).digest() + return base64.b64encode(payload_hash) + + +def compare_signatures(signature_1, signature_2): + alphanumeric_characters = re.compile("[^a-zA-Z0-9]") + sig1safe = re.sub(alphanumeric_characters, "", signature_1) + sig2safe = re.sub(alphanumeric_characters, "", signature_2) + return sig1safe == sig2safe + + +def validate_payment_for_order(request, order: PaymentTPV) -> Decimal: + """ + example_response_data = { + 'Ds_MerchantCode': '999008881', + 'Ds_Terminal': '001', + 'Ds_Order': 'a082bd1cda0b488786f23a62e89107c0', + 'Ds_Amount': '13532', + 'Ds_Currency': '978', + 'Ds_Date': '20/03/2024', + 'Ds_Hour': '12:21', + 'Ds_SecurePayment': '1', + 'Ds_Card_Number': '454881******1156', + 'Ds_Card_Country': '724', + 'Ds_Response': '0000', + 'Ds_MerchantData': '', + 'Ds_TransactionType': '0', + 'Ds_ConsumerLanguage': '1', + 'Ds_AuthorisationCode': '182670', + 'Ds_Card_Brand': '1', + 'Ds_ProcessedPayMethod': '80', + 'Ds_ECI': '05', + 'Ds_Response_Description': 'OPERACION AUTORIZADA' + } + + :param request: + :param order: + :return: + """ + data = request.POST + + merchant_parameters = data.get("Ds_MerchantParameters") + + if not merchant_parameters: + raise RedsysValidationException( + _("No se ha recibido ningún valor para Ds_MerchantParameters") + ) + + merchant_params = base64.b64decode(merchant_parameters).decode() + result = json.loads(merchant_params) + + order_hex = result.get("Ds_Order") + assert order_hex == order.hash.hex + + status_code = result.get("Ds_Response") + + if int(status_code) > 100: + reason = ERROR_CODES.get(status_code, _("Error no tipificado")) + + raise RedsysPaymentException(_(f"No se ha realizado el pago. Motivo: {reason}")) + + amount = Decimal(result.get("Ds_Amount")) / 100 + + return amount + + +def pay_order(order: PaymentTPV, amount_paid: Decimal): + order.status = PaymentTPV.StatusChoices.PAID + order.save() diff --git a/tpv/views.py b/tpv/views.py new file mode 100644 index 0000000..e5bf9d0 --- /dev/null +++ b/tpv/views.py @@ -0,0 +1,111 @@ +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render, get_object_or_404, redirect +from django.http.response import HttpResponse + +from tpv.forms import UpdateEmailForm +from tpv.models import PaymentTPV +from tpv.redsys import RedsysClient +from tpv.utils import pay_order, validate_payment_for_order +from tpv.signals import redsys_payment_accepted, redsys_payment_rejected + + +def payment_accepted(request, order): + order = get_object_or_404(PaymentTPV, hash=order) + + # Ñapa histórica + if request.GET: + return redirect(reverse("tpv:ok", kwargs={"order": order.hash})) + + return render( + request, + template_name="tpv/order_created.html", + context={ + "order": order, + }, + ) + + +def payment_rejected(request, order): + order = get_object_or_404(PaymentTPV, hash=order) + + # Ñapa histórica + if request.GET: + return redirect(reverse("tpv:ko", kwargs={"order": order.hash})) + + return render( + request, + template_name="tpv/order.html", + context={ + "order": order, + }, + ) + + +@csrf_exempt +def webhook(request, order): + order = get_object_or_404(PaymentTPV, hash=order) + + try: + amount_paid = validate_payment_for_order(request, order) + pay_order(order, amount_paid) + redsys_payment_accepted.send_robust(PaymentTPV.__class__, hash=order.hash) + + return HttpResponse(status=200) + except Exception as e: + order.status = PaymentTPV.StatusChoices.ERROR + order.observations = str(e) + order.save() + redsys_payment_rejected.send_robust(PaymentTPV.__class__, hash=order.hash) + + return HttpResponse(status=409) + + +def order_created(request, order): + order = get_object_or_404(PaymentTPV, hash=order) + + client = RedsysClient() + parameters = client.get_body_for_order(order) + + if request.method == "GET": + return render( + request, + template_name="tpv/order_created.html", + context={ + "form": UpdateEmailForm({"contact_email": order.contact_email}), + "action": reverse("tpv:order_created", kwargs={"order": order.hash}), + "order": order, + "signature_version": parameters.get("Ds_SignatureVersion"), + "merchant_parameters": parameters.get("Ds_MerchantParameters"), + "signature": parameters.get("Ds_Signature"), + "redsys_target_url": client.get_target_url(), + }, + ) + + elif request.method == "POST": + form = UpdateEmailForm(request.POST) + + if form.is_valid(): + order.contact_email = form.cleaned_data["contact_email"] + order.save() + + return redirect(reverse("tpv:order_created", kwargs={"order": order.hash})) + else: + return render( + request, + template_name="tpv/order_created.html", + context={ + "form": UpdateEmailForm({"contact_email": ""}), + "errors": form.errors, + "action": reverse( + "tpv:order_created", kwargs={"order": order.hash} + ), + "order": order, + "signature_version": parameters.get("Ds_SignatureVersion"), + "merchant_parameters": parameters.get("Ds_MerchantParameters"), + "signature": parameters.get("Ds_Signature"), + "redsys_target_url": client.get_target_url(), + }, + ) + + return HttpResponse(status=405)