feat: added tpv app

This commit is contained in:
Pablo Moreno
2024-03-22 00:44:49 +01:00
parent a13eb6c9d9
commit b8562b3d7b
27 changed files with 1153 additions and 7 deletions
+3 -1
View File
@@ -5,6 +5,8 @@ from config.api.v1.views import APISchema
urlpatterns = [ urlpatterns = [
path("schema/", APISchema.as_view(), name="schema"), 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"), path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
] ]
-1
View File
@@ -3,4 +3,3 @@ from drf_spectacular.views import SpectacularAPIView
class APISchema(SpectacularAPIView): class APISchema(SpectacularAPIView):
api_version = "v1" api_version = "v1"
+15 -4
View File
@@ -1,9 +1,9 @@
import datetime import datetime
from config.settings.environ import * # noqa from config.settings.environ import * # noqa
APP_NAME = 'template' APP_NAME = "TPV"
DESCRIPTION = '' DESCRIPTION = ""
VERSION = '0.1.0' VERSION = "0.1.0"
DJANGO_APPS = [ DJANGO_APPS = [
@@ -26,7 +26,7 @@ THIRD_PARTY_APPS = [
] ]
PROJECT_APPS = [ PROJECT_APPS = [
"tpv",
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS 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_BROKER_URL = env.str("CELERY_BROKER_URL", default="redis://172.17.0.1:6379/0")
CELERY_TIME_ZONE = TIME_ZONE CELERY_TIME_ZONE = TIME_ZONE
CELERY_ALWAYS_EAGER = DEBUG 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", "")
+1
View File
@@ -6,4 +6,5 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/v1/", include("config.api.v1.urls")), path("api/v1/", include("config.api.v1.urls")),
path("watchman", include("watchman.urls")), path("watchman", include("watchman.urls")),
path("tpv/", include("tpv.urls", namespace="tpv")),
] ]
+3
View File
@@ -0,0 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.develop
addopts = --ignore=src
+3 -1
View File
@@ -1,6 +1,7 @@
celery==5.2.7 celery==5.2.7
django==5.0.1 django==5.0.3
django-cors-headers==3.13.0 django-cors-headers==3.13.0
django-cryptography==1.1
django-extensions==3.2.0 django-extensions==3.2.0
django-filter==23.1 django-filter==23.1
django-environ==0.10.0 django-environ==0.10.0
@@ -13,6 +14,7 @@ drf-spectacular==0.26.1
ipython==8.16.1 ipython==8.16.1
Pillow==10.0.1 Pillow==10.0.1
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
pyDes==2.0.1
redis==4.5.4 redis==4.5.4
requests==2.31.0 requests==2.31.0
uvicorn==0.21.1 uvicorn==0.21.1
View File
+12
View File
@@ -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",
)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TpvConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "tpv"
+6
View File
@@ -0,0 +1,6 @@
class RedsysValidationException(Exception):
pass
class RedsysPaymentException(Exception):
pass
+14
View File
@@ -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"
}
),
)
+99
View File
@@ -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",
},
),
]
View File
+75
View File
@@ -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")
+105
View File
@@ -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,
}
+254
View File
@@ -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"),
}
+5
View File
@@ -0,0 +1,5 @@
from django.dispatch import Signal
redsys_payment_accepted = Signal()
redsys_payment_rejected = Signal()
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Pago de facturas</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {}
</script>
<style>
@layer utilities {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.top-bar {
background: linear-gradient(50deg, #02B0C4 0%, #A0D720 100%);
padding: 32px;
color: white;
display: flex;
justify-content: center;
}
</style>
</head>
<body>
{% include 'tpv/header.html' %}
{% block body %}
{% endblock %}
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<div class="justify-between mb-6 rounded-lg bg-white p-6 shadow-md sm:flex sm:justify-start">
<div class="sm:ml-4 sm:flex sm:w-full sm:justify-between">
<div class="mt-5 sm:mt-0">
<h2 class="text-lg font-bold text-gray-900">Factura #{{ item.id }}</h2>
<p class="mt-1 text-xs text-gray-700">
{{ item.name }} - {{ item.description }}
</p>
</div>
<div class="mt-4 flex justify-between sm:space-y-6 sm:mt-0 sm:block sm:space-x-6">
<div class="flex items-center border-gray-100">
{{ item.amount_to_pay }} €
</div>
</div>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
<header class="top-bar">
<img src="" alt="">
</header>
+102
View File
@@ -0,0 +1,102 @@
{% extends 'tpv/base.html' %}
{% block body %}
<div class="h-screen bg-gray-100 pt-10">
<header class="mb-10 text-center">
<h1 class="text-2xl font-bold p-4">Resumen de pedido</h1>
{% if order.status == 'PAI' %}
<span
class="bg-green-100 text-green-800 text-large font-large me-2 px-2.5 py-0.5 rounded-full dark:bg-green-400 dark:text-green-900">Pagado</span>
{% elif order.status == 'PEN' %}
<span
class="bg-yellow-100 text-yellow-800 text-large font-large me-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-400 dark:text-yellow-900">Pendiente de pago</span>
{% else %}
<span
class="bg-red-100 text-red-800 text-large font-large me-2 px-2.5 py-0.5 rounded-full dark:bg-red-400 dark:text-red-900">Error de pago</span>
{% endif %}
</header>
<div class="mx-auto max-w-5xl justify-center px-6 md:flex md:space-x-6 xl:px-0">
<div class="rounded-lg md:w-2/3">
{% for item in order.metadata.items %}
{% include 'tpv/cart_item.html' %}
{% endfor %}
</div>
<div class=" md:w-1/3">
{% if order.status == 'PEN' %}
<!-- Formulario de email -->
<section class="mt-6 rounded-lg border bg-white shadow-md md:mt-0 mb-4">
<div class="flex flex-col items-center justify-center mx-auto">
<div
class="w-full rounded-lg md:mt-0 sm:max-w-md xl:p-0">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<form class="space-y-4 md:space-y-6" action="{{ action }}" method="POST">
{% csrf_token %}
{% for field in form %}
<label class="font-bold" for="{{ field.auto_id }}">{{ field.label }}</label>
{{ field }}
{% endfor %}
<button type="submit" class="mt-6 w-full rounded-md bg-blue-500 py-1.5 font-medium text-blue-50 hover:bg-blue-600 cursor-pointer">
Enviar
</button>
</form>
</div>
</div>
</div>
</section>
{% endif %}
<section class="mt-6 rounded-lg border bg-white p-6 shadow-md md:mt-0">
<div class="mb-2 flex justify-between">
<p class="text-gray-700">Subtotal</p>
<p class="text-gray-700">{{ order.amount }} €</p>
</div>
<hr class="my-4"/>
<div class="flex justify-between">
<p class="text-lg font-bold">Total</p>
<div class="">
<p class="mb-1 text-lg font-bold">{{ order.amount }} €</p>
<p class="text-sm text-gray-700">Impuestos incluidos</p>
</div>
</div>
{% if order.status == 'PEN' %}
<form name="from" action="{{ redsys_target_url }}" method="POST">
{% csrf_token %}
<input type="hidden" name="Ds_SignatureVersion" value="{{ signature_version }}"/>
<input type="hidden" name="Ds_MerchantParameters" value="{{ merchant_parameters }}"/>
<input type="hidden" name="Ds_Signature" value="{{ signature }}"/>
{% if order.contact_email != '' %}
<input
class="mt-6 w-full rounded-md bg-blue-500 py-1.5 font-medium text-blue-50 hover:bg-blue-600 cursor-pointer"
type="submit" value="Ir a pagar">
{% else %}
<input
class="mt-6 w-full rounded-md bg-blue-500 py-1.5 font-medium text-blue-50 hover:bg-blue-600 cursor-pointer"
type="submit" value="Ir a pagar" disabled>
{% endif %}
</form>
{% endif %}
</section>
</div>
</div>
</div>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
View File
+169
View File
@@ -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
+12
View File
@@ -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/<str:order>/", order_created, name="order_created"),
path("order/<str:order>/webhook/", webhook, name="webhook"),
path("order/<str:order>/ok/", payment_accepted, name="ok"),
path("order/<str:order>/ko/", payment_rejected, name="ko"),
]
+99
View File
@@ -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()
+111
View File
@@ -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)