feat: changed TPV behaviour

This commit is contained in:
Pablo Moreno
2025-01-12 22:40:46 +01:00
parent fc0cdad246
commit a733325529
43 changed files with 1036 additions and 1100 deletions
+11
View File
@@ -14,6 +14,7 @@ from shop.models import (
ProductPrice,
Provider,
ShippingMethod,
ShopSettings,
Tag,
Tax,
)
@@ -155,3 +156,13 @@ class ProductCategoryAdmin(ModelAdmin):
"hidden",
"show_in_navbar",
)
@admin.register(ShopSettings)
class ShopSettingsAdmin(ModelAdmin):
list_display = (
"id",
"merchant_code",
"currency_code",
"terminal",
)
+6
View File
@@ -0,0 +1,6 @@
class RedsysValidationException(Exception):
pass
class RedsysPaymentException(Exception):
pass
+3 -2
View File
@@ -1,9 +1,10 @@
# Generated by Django 5.1.3 on 2025-01-03 11:17
import uuid
from decimal import Decimal
import django.db.models.deletion
import django.utils.timezone
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
@@ -0,0 +1,344 @@
# Generated by Django 5.1.4 on 2025-01-12 13:03
import uuid
from decimal import Decimal
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import shop.models
class Migration(migrations.Migration):
dependencies = [
("shop", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ShopSettings",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"merchant_code",
models.CharField(
max_length=9, verbose_name="Identificación de comercio"
),
),
(
"currency_code",
models.CharField(
choices=[
("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"),
],
default="978",
max_length=4,
verbose_name="Código de moneda",
),
),
("terminal", models.CharField(max_length=8, verbose_name="Terminal")),
(
"shared_secret",
models.CharField(max_length=100, verbose_name="Clave de Redsys"),
),
],
options={
"verbose_name": "ajustes de la tienda",
"verbose_name_plural": "ajustes de la tienda",
},
),
migrations.AddField(
model_name="order",
name="code",
field=models.CharField(
default=shop.models.create_order_code, max_length=20, unique=True
),
),
migrations.AddField(
model_name="order",
name="shipping_status",
field=models.CharField(
default="NOT", max_length=3, verbose_name="estado de envío"
),
),
migrations.AddField(
model_name="product",
name="is_shipping_method",
field=models.BooleanField(default=False, verbose_name="es forma de envío"),
),
migrations.CreateModel(
name="Payment",
fields=[
(
"hash",
models.UUIDField(
default=uuid.uuid4,
primary_key=True,
serialize=False,
verbose_name="Hash",
),
),
(
"amount",
models.DecimalField(
decimal_places=2,
default=Decimal("0"),
max_digits=8,
verbose_name="Cantidad",
),
),
(
"creation_date",
models.DateTimeField(
auto_now_add=True, verbose_name="Fecha de creación"
),
),
("metadata", models.JSONField(default=dict, verbose_name="Metadata")),
(
"method",
models.CharField(
choices=[("REDSYS", "Redsys")],
default="REDSYS",
max_length=6,
verbose_name="método de pago",
),
),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="shop.order",
verbose_name="pedido",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
verbose_name="cliente",
),
),
],
options={
"verbose_name": "Pago",
"verbose_name_plural": "Pagos",
},
),
]
+91
View File
@@ -8,6 +8,9 @@ from django.utils import timezone
from django.utils.text import gettext_lazy as _
from django.utils.text import slugify
from config.models import SingletonModel
from shop.settings import CURRENCY_CODES
User = get_user_model()
@@ -118,6 +121,9 @@ class Product(TimestampedModel):
default="", blank=True, null=True, max_length=128, verbose_name=_("Slug")
)
hidden = models.BooleanField(default=False, db_index=True, verbose_name=_("oculto"))
is_shipping_method = models.BooleanField(
default=False, verbose_name=_("es forma de envío")
)
def __str__(self):
return self.name
@@ -328,30 +334,55 @@ class OrderLine(TimestampedModel):
verbose_name_plural = _("líneas de pedido")
def create_order_code(*args, **kwargs):
return (
timezone.now()
.isoformat()
.replace("-", "")
.replace("T", "")
.replace(":", "")
.replace(".", "")[:-5]
)
class Order(TimestampedModel):
class Statuses(models.TextChoices):
STATUS_PENDING = "PEN", _("pendiente de pago")
STATUS_PAID = "PAI", _("pagado")
STATUS_RETURNED = "RTN", _("devuelto")
STATUS_ERROR = "ERR", _("Error")
class ShippingStatuses(models.TextChoices):
STATUS_NOT_READY = "NOT", _("no preparado")
STATUS_READY = "RDY", _("preparado")
STATUS_TO_BE_SENT = "TBS", _("listo para ser enviado")
STATUS_SENT = "SNT", _("enviado")
STATUS_DELIVERED = "DLV", _("entregado")
STATUS_FINISHED = "FIN", _("finalizado")
STATUS_CANCELED = "CAN", _("cancelado")
STATUS_REQUESTED_RETURN = "RQT", _("devolución solicitada")
STATUS_RETURNED = "RTN", _("devuelto")
uuid = models.UUIDField(default=uuid4, verbose_name=_("UUID"), db_index=True)
code = models.CharField(default=create_order_code, max_length=20, unique=True)
status = models.CharField(
max_length=3, default=Statuses.STATUS_PENDING, verbose_name=_("estado")
)
shipping_status = models.CharField(
max_length=3,
default=ShippingStatuses.STATUS_NOT_READY,
verbose_name=_("estado de envío"),
)
base_total = models.DecimalField(
default=Decimal("0"),
max_digits=13,
decimal_places=2,
verbose_name=_("base imponible"),
)
total = models.DecimalField(
default=Decimal("0"), max_digits=13, decimal_places=2, verbose_name=_("total")
)
@@ -536,3 +567,63 @@ class WishlistedProduct(TimestampedModel):
"user",
"product",
)
class Payment(models.Model):
class MethodChoices(models.TextChoices):
REDSYS = "REDSYS"
hash = models.UUIDField(verbose_name=_("Hash"), default=uuid4, primary_key=True)
amount = models.DecimalField(
max_digits=8,
decimal_places=2,
default=Decimal("0"),
verbose_name=_("Cantidad"),
)
order = models.ForeignKey(
"shop.Order",
on_delete=models.PROTECT,
verbose_name=_("pedido"),
related_name="payments",
)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.PROTECT, verbose_name=_("cliente")
)
creation_date = models.DateTimeField(
auto_now_add=True, verbose_name=_("Fecha de creación")
)
metadata = models.JSONField(default=dict, verbose_name=_("Metadata"))
method = models.CharField(
max_length=6,
choices=MethodChoices.choices,
default=MethodChoices.REDSYS,
verbose_name=_("método de pago"),
)
class Meta:
verbose_name = _("Pago")
verbose_name_plural = _("Pagos")
class ShopSettings(SingletonModel):
merchant_code = models.CharField(
max_length=9,
blank=False,
null=False,
verbose_name=_("Identificación de comercio"),
)
currency_code = models.CharField(
max_length=4,
default="978",
choices=CURRENCY_CODES,
verbose_name=_("Código de moneda"),
) # 978 == EURO
terminal = models.CharField(max_length=8, verbose_name=_("Terminal"))
shared_secret = models.CharField(max_length=100, verbose_name=_("Clave de Redsys"))
class Meta:
verbose_name = _("ajustes de la tienda")
verbose_name_plural = _("ajustes de la tienda")
+209
View File
@@ -0,0 +1,209 @@
import base64
import json
from decimal import Decimal
import requests
from django.conf import settings
from django.urls import reverse
from shop.exceptions import RedsysPaymentException
from shop.models import Order, Payment
from shop.settings import TransactionTypes
from shop.utils import compute_signature, decode_b64_dict
class RedsysClient:
DEBUG_ENVIRONMENT_URL = "https://sis-t.redsys.es:25443/sis/realizarPago"
PROD_ENVIRONMENT_URL = "https://sis.redsys.es/sis/realizarPago"
REST_DEBUG_ENVIRONMENT_URL = (
"https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST"
)
REST_PROD_ENVIRONMENT_URL = "https://sis.redsys.es/sis/rest/trataPeticionREST"
def get_target_url(self):
if settings.DEBUG:
return self.DEBUG_ENVIRONMENT_URL
return self.PROD_ENVIRONMENT_URL
def get_rest_target_url(self):
if settings.DEBUG:
return self.REST_DEBUG_ENVIRONMENT_URL
return self.REST_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: Order) -> str:
path = reverse("web:order", kwargs={"uuid": order.uuid})
return f"{settings.REDSYS_TPV_DOMAIN}{path}"
def to_integer(self, value: Decimal):
return int(value * 100)
def get_merchant_url_ko_for_order(self, order: Order) -> str:
path = reverse("web:order", kwargs={"uuid": order.uuid})
return f"{settings.REDSYS_TPV_DOMAIN}{path}"
def get_webhook_url_for_order(self, order: Order) -> str:
path = reverse("shop:webhook", kwargs={"uuid": order.uuid})
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: Order,
order_type: int = TransactionTypes.AUTHORIZATION,
) -> dict:
merchant_code = self.get_merchant_code()
return {
"DS_MERCHANT_AMOUNT": str(self.to_integer(order.total)),
"DS_MERCHANT_CURRENCY": self.get_currency_code(),
"DS_MERCHANT_MERCHANTCODE": merchant_code,
# Webhook
"DS_MERCHANT_MERCHANTURL": self.get_webhook_url_for_order(order),
"DS_MERCHANT_ORDER": order.code,
"DS_MERCHANT_TERMINAL": self.get_terminal(),
"DS_MERCHANT_TRANSACTIONTYPE": order_type,
# Página informativa al usuario - Pago erróneo
"DS_MERCHANT_URLKO": self.get_merchant_url_ko_for_order(order),
# Página informativa al usuario - Pago confirmado
"DS_MERCHANT_URLOK": self.get_merchant_url_ok_for_order(order),
}
def _get_encoded_merchant_parameters_for_order(
self,
order: Order,
order_type: int = TransactionTypes.AUTHORIZATION,
) -> str:
body = self._get_merchant_parameters_for_order(order, order_type)
return self._encode_body(body)
def get_body_for_order(
self,
order: Order,
order_type: int = TransactionTypes.AUTHORIZATION,
) -> dict:
merchant_parameters = self._get_encoded_merchant_parameters_for_order(
order, order_type
)
signature = self.get_signature(order.code, merchant_parameters)
return {
"Ds_MerchantParameters": merchant_parameters,
"Ds_SignatureVersion": "HMAC_SHA256_V1",
"Ds_Signature": signature,
}
def _encode_body(self, value):
stringified_body = json.dumps(value)
return base64.b64encode(stringified_body.encode()).decode("utf-8")
def _get_rest_merchant_parameters_for_order(
self,
order: Order,
pan: str,
expiry_date: str,
cvv2: str,
order_type: int = TransactionTypes.AUTHORIZATION,
) -> dict:
merchant_code = self.get_merchant_code()
return {
"DS_MERCHANT_AMOUNT": str(self.to_integer(order.total)),
"DS_MERCHANT_CURRENCY": self.get_currency_code(),
"DS_MERCHANT_CVV2": cvv2,
"DS_MERCHANT_EXPIRYDATE": expiry_date,
"DS_MERCHANT_MERCHANTCODE": merchant_code,
"DS_MERCHANT_ORDER": order.uuid.hex,
"DS_MERCHANT_PAN": pan,
"DS_MERCHANT_TERMINAL": "1",
"DS_MERCHANT_TRANSACTIONTYPE": order_type,
}
def _get_rest_encoded_merchant_parameters_for_order(
self,
order: Order,
pan: str,
expiry_date: str,
cvv2: str,
order_type: int = TransactionTypes.AUTHORIZATION,
) -> str:
body = self._get_rest_merchant_parameters_for_order(
order,
pan=pan,
expiry_date=expiry_date,
cvv2=cvv2,
order_type=order_type,
)
return self._encode_body(body)
def _get_rest_body_for_order(
self,
order: Order,
pan: str,
expiry_date: str,
cvv2: str,
order_type: int = TransactionTypes.AUTHORIZATION,
) -> dict:
merchant_parameters = self._get_rest_encoded_merchant_parameters_for_order(
order=order,
pan=pan,
expiry_date=expiry_date,
cvv2=cvv2,
order_type=order_type,
)
signature = self.get_signature(order.code, merchant_parameters)
return {
"Ds_MerchantParameters": merchant_parameters,
"Ds_SignatureVersion": "HMAC_SHA256_V1",
"Ds_Signature": signature,
}
def make_request_for_order(
self,
order: Order,
pan: str,
expiry_date: str,
cvv2: str,
order_type: int = TransactionTypes.AUTHORIZATION,
):
body = self._get_rest_body_for_order(
order=order,
pan=pan,
expiry_date=expiry_date,
cvv2=cvv2,
order_type=order_type,
)
url = self.get_rest_target_url()
response = requests.post(url, body)
return response
def pay_order_rest(self, order: Order, pan: str, expiry_date: str, cvv2: str):
response = self.make_request_for_order(order, pan, expiry_date, cvv2)
data = response.json()
error_code: str = data.get("errorCode", "")
if error_code:
error_code.replace("SIS0", "0")
raise RedsysPaymentException(error_code)
merchant_parameters = data.get("Ds_MerchantParameters")
parameters = decode_b64_dict(merchant_parameters)
return parameters
+274
View File
@@ -0,0 +1,274 @@
from django.utils.text import gettext_lazy as _
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
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",
}
REVERSE_CURRENCY_CODES = {v: k for k, v in CURRENCY_CODES.items()}
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"),
}
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
+186
View File
@@ -0,0 +1,186 @@
import base64
import json
from decimal import Decimal
from django.conf import settings
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from shop.models import CustomerAddress, Order, Tax
from shop.redsys import RedsysClient
from shop.tests.mixins import CreateProductsMixin
from shop.utils import create_order, create_order_line_for_product, validate_expiry_date
User = get_user_model()
class TestRedsysTPV(APITestCase, CreateProductsMixin):
def setUp(self):
self.tax = Tax.objects.create(
code="IVA",
value=21,
)
self.customer = get_user_model().objects.create_user(
username="11111111H",
first_name="Darth",
last_name="Maull",
email="darth@maul.com",
password="dathomir",
)
self.customer_shipping_address = CustomerAddress.objects.create(
user=self.customer,
address="Dathomir",
address_town="Dathomir",
address_zip="00001",
address_state="Dathomir",
address_phone="900000000",
address_type=CustomerAddress.Types.SHIPPING,
)
self.customer_billing_address = CustomerAddress.objects.create(
user=self.customer,
address="Dathomir",
address_town="Dathomir",
address_zip="00001",
address_state="Dathomir",
address_phone="900000000",
address_type=CustomerAddress.Types.BILLING,
)
self.product = self.create_product()
self.order = self.create_order()
self.order.calculate_total_from_lines()
def create_order(self):
order = create_order(
customer=self.customer,
billing_address=self.customer_billing_address.address,
billing_city=self.customer_billing_address.address_town,
billing_state=self.customer_billing_address.address_state,
billing_zip=self.customer_billing_address.address_zip,
billing_country=self.customer_billing_address.address_country,
)
l1 = create_order_line_for_product(
self.product,
quantity=Decimal("1.0"),
order=order,
)
return order
def test_redsys_client(self):
amount_to_pay = Decimal("12.10")
client = RedsysClient()
merchant_parameters = client._get_merchant_parameters_for_order(
self.order,
)
assert merchant_parameters.get("DS_MERCHANT_ORDER") == self.order.code
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(self.order)
def test_redsys_webhook(self):
redsys_response_data = {
"Ds_MerchantCode": "999008881",
"Ds_Terminal": "001",
"Ds_Order": self.order.code,
"Ds_Amount": str(self.order.total * 100),
"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("shop:webhook", kwargs={"uuid": self.order.uuid}),
data={
"Ds_MerchantParameters": b64_merchant_params,
"Ds_Signature": settings.REDSYS_SHARED_SECRET,
"Ds_SignatureVersion": "HMAC_SHA256_V1",
},
)
self.order.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert self.order.status == Order.Statuses.STATUS_PAID
def test_redsys_webhook_payment_error(self):
redsys_response_data = {
"Ds_MerchantCode": "999008881",
"Ds_Terminal": "001",
"Ds_Order": self.order.code,
"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": "ERROR",
}
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("shop:webhook", kwargs={"uuid": self.order.uuid}),
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
self.order.refresh_from_db()
assert self.order.status == Order.Statuses.STATUS_ERROR
def test_expiry_date(self):
now = timezone.now()
previous_year = str(now.year - 1).rjust(2, "0")
current_month = str(now.month).rjust(2, "0")
current_year = str(now.year)[-2:]
assert not validate_expiry_date("042024")
assert not validate_expiry_date(f"{previous_year}{current_month}")
assert validate_expiry_date(f"{current_year}{current_month}")
+5 -1
View File
@@ -1,6 +1,10 @@
from django.urls import path
from shop.views import webhook
app_name = "shop"
urlpatterns = []
urlpatterns = [
path("order/<str:uuid>/webhook/", webhook, name="webhook"),
]
+125
View File
@@ -1,17 +1,28 @@
import base64
import hashlib
import hmac
import json
from decimal import Decimal
import pyDes
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django.utils.text import gettext_lazy as _
from shop.exceptions import RedsysPaymentException, RedsysValidationException
from shop.models import (
Cart,
CustomerAddress,
Order,
OrderLine,
Payment,
Product,
ProductBatch,
ProductPrice,
ShippingMethod,
)
from shop.settings import ERROR_CODES
User = get_user_model()
@@ -148,3 +159,117 @@ def add_shipping_order_line(order, shipping_method):
total=shipping_total,
)
order.calculate_total_from_lines()
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 validate_payment_for_order(request, order: Order) -> 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'
}
Raises AssertionError
"""
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 = decode_b64_string(merchant_parameters)
result = json.loads(merchant_params)
order_code = result.get("Ds_Order")
assert order_code == order.code
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 decode_b64_string(value: str):
return base64.b64decode(value).decode()
def decode_b64_dict(value: str):
return json.loads(decode_b64_string(value))
def validate_expiry_date(expiry_date: str):
if len(expiry_date) != 4:
return False
month, year = int(expiry_date[2:4]), int(expiry_date[:2])
if month < 1 or month > 12:
return False
current_year = int(str(timezone.now().year)[2:])
current_month = timezone.now().month
if year < current_year:
return False
if year == current_year and month > current_month:
return False
return True
def pay_order(order: Order, amount_paid: Decimal):
with transaction.atomic() as tx:
payment = Payment.objects.create(
amount=amount_paid,
order=order,
user=order.user,
method=Payment.MethodChoices.REDSYS,
)
if amount_paid >= order.total:
order.status = Order.Statuses.STATUS_PAID
order.save()
+22
View File
@@ -0,0 +1,22 @@
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from shop.models import Order
from shop.utils import validate_payment_for_order, pay_order
@csrf_exempt
def webhook(request, uuid):
order = get_object_or_404(Order, uuid=uuid)
try:
amount_paid = validate_payment_for_order(request, order)
pay_order(order, amount_paid)
return HttpResponse(status=200)
except Exception as e:
order.status = Order.Statuses.STATUS_ERROR
order.save()
return HttpResponse(status=409)