From 6b2c25e8498e5e45a1578808b92e454d1a55962a Mon Sep 17 00:00:00 2001 From: ivan andres Date: Mon, 16 Dec 2024 22:29:16 -0500 Subject: [PATCH 01/10] feat: improvement of property formats for sending requests to the Checkout api --- checkout/decorators/filter_empty_values.py | 28 +++++ checkout/entities/amount.py | 10 +- checkout/entities/dispersion_payment.py | 5 +- checkout/entities/name_value_pair.py | 12 +- checkout/entities/payment.py | 38 ++++--- checkout/entities/person.py | 20 +++- checkout/entities/recurring.py | 24 ++-- checkout/entities/subscription.py | 12 +- checkout/messages/requests/collect.py | 6 +- checkout/messages/requests/redirect.py | 28 ++++- checkout/mixins/fields_mixin.py | 18 +-- .../unit/entities/test_dispersion_payment.py | 65 +---------- .../unit/entities/test_name_value_pair.py | 10 +- checkout/tests/unit/entities/test_payment.py | 66 ++++++++--- checkout/tests/unit/entities/test_person.py | 13 +-- .../tests/unit/entities/test_recurring.py | 38 +++---- .../tests/unit/entities/test_subscription.py | 15 ++- .../unit/messages/requests/test_collect.py | 1 - .../unit/messages/requests/test_redirect.py | 32 +++++- .../tests/unit/mixins/test_fields_mixin.py | 103 +----------------- 20 files changed, 276 insertions(+), 268 deletions(-) create mode 100644 checkout/decorators/filter_empty_values.py diff --git a/checkout/decorators/filter_empty_values.py b/checkout/decorators/filter_empty_values.py new file mode 100644 index 0000000..eb4f6b9 --- /dev/null +++ b/checkout/decorators/filter_empty_values.py @@ -0,0 +1,28 @@ +from functools import wraps +from typing import Any, Callable + + +def filter_empty_values(func: Callable) -> Callable: + """ + Decorador para eliminar valores vacíos (None, '', {}, []) de un diccionario. + """ + + @wraps(func) + def wrapper(*args: tuple, **kwargs: dict[str, Any]) -> Any: + data = func(*args, **kwargs) + if isinstance(data, dict): + return remove_empty_values(data) + return data + + return wrapper + + +def remove_empty_values(data: Any) -> Any: + """ + Elimina recursivamente los valores vacíos (None, '', {}, []) de un diccionario o lista. + """ + if isinstance(data, dict): + return {k: remove_empty_values(v) for k, v in data.items() if v not in (None, "", {}, [])} + elif isinstance(data, list): + return [remove_empty_values(item) for item in data if item not in (None, "", {}, [])] + return data diff --git a/checkout/entities/amount.py b/checkout/entities/amount.py index 92caffa..88dfaf9 100644 --- a/checkout/entities/amount.py +++ b/checkout/entities/amount.py @@ -46,8 +46,16 @@ def to_dict(self) -> dict: Convert the Amount object to a dictionary including taxes and details. """ parent_data = super().to_dict() - return { + + data = { **parent_data, "taxes": self.taxes_to_dict(), "details": self.details_to_dict(), } + + if self.tip is None: + del data["tip"] + if self.insurance is None: + del data["insurance"] + + return data diff --git a/checkout/entities/dispersion_payment.py b/checkout/entities/dispersion_payment.py index b03e6e1..d312128 100644 --- a/checkout/entities/dispersion_payment.py +++ b/checkout/entities/dispersion_payment.py @@ -23,7 +23,7 @@ def _extract_payment_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: "reference": data.get("reference", ""), "description": data.get("description", ""), "amount": data.get("amount"), - "allowPartial": data.get("allowPartial", False), + "allow_partial": data.get("allow_partial", False), "shipping": data.get("shipping"), "items": data.get("items", []), "recurring": data.get("recurring"), @@ -31,8 +31,9 @@ def _extract_payment_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: "discount": data.get("discount"), "subscribe": data.get("subscribe", False), "agreement": data.get("agreement"), - "agreementType": data.get("agreementType", ""), + "agreement_type": data.get("agreement_type", ""), "modifiers": data.get("modifiers", []), + "custom_fields": data.get("custom_fields", []), } def set_dispersion(self, data: Union[List[Dict], Dict]) -> "DispersionPayment": diff --git a/checkout/entities/name_value_pair.py b/checkout/entities/name_value_pair.py index 47578f3..2a7327b 100644 --- a/checkout/entities/name_value_pair.py +++ b/checkout/entities/name_value_pair.py @@ -1,6 +1,6 @@ -from typing import Any, Optional +from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from checkout.enums.display_on_enum import DisplayOnEnum @@ -8,12 +8,14 @@ class NameValuePair(BaseModel): keyword: str = Field(..., description="The keyword associated with the value") value: Any = Field(default=None, description="The value, which can be a string, list, or dict") - displayOn: Optional[DisplayOnEnum] = Field( - default=DisplayOnEnum.NONE, description="Display setting for the keyword" + display_on: DisplayOnEnum = Field( + default=DisplayOnEnum.NONE, description="Display setting for the keyword", alias="displayOn" ) + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) + def to_dict(self) -> dict: """ Convert the NameValuePair object to a dictionary using the Pydantic `model_dump` method. """ - return self.model_dump() + return {"keyword": self.keyword, "value": self.value, "displayOn": self.display_on.value} diff --git a/checkout/entities/payment.py b/checkout/entities/payment.py index f6b54ab..14c8fa1 100644 --- a/checkout/entities/payment.py +++ b/checkout/entities/payment.py @@ -1,11 +1,11 @@ from typing import List, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from checkout.entities.amount import Amount from checkout.entities.discount import Discount -from checkout.entities.instrument import Instrument from checkout.entities.item import Item +from checkout.entities.name_value_pair import NameValuePair from checkout.entities.payment_modifier import PaymentModifier from checkout.entities.person import Person from checkout.entities.recurring import Recurring @@ -15,17 +15,19 @@ class Payment(BaseModel, FieldsMixin): reference: str = Field(..., description="Payment reference") description: str = Field(default="", description="Description of the payment") - amount: Optional[Amount] = Field(default=None, description="Amount information") - allowPartial: bool = Field(default=False, description="Allow partial payments") + amount: Amount = Field(default=..., description="Amount information") + allow_partial: bool = Field(default=False, description="Allow partial payments", alias="allowPartial") shipping: Optional[Person] = Field(default=None, description="Shipping details") items: List[Item] = Field(default_factory=list, description="List of items") recurring: Optional[Recurring] = Field(default=None, description="Recurring payment details") - payment: Optional[Instrument] = Field(default=None, description="Instrument payment details") discount: Optional[Discount] = Field(default=None, description="Discount information") subscribe: bool = Field(default=False, description="Subscribe flag") agreement: Optional[int] = Field(default=None, description="Agreement ID") - agreementType: str = Field(default="", description="Type of agreement") + agreement_type: str = Field(default="", description="Type of agreement", alias="agreementType") modifiers: List[PaymentModifier] = Field(default_factory=list, description="List of payment modifiers") + custom_fields: List[NameValuePair] = Field(default=[], description="Additional fields for the payment") + + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) def set_items(self, items: Union[List[dict], List[Item]]) -> None: """ @@ -55,18 +57,26 @@ def to_dict(self) -> dict: """ Convert the Payment object to a dictionary, including nested objects. """ - return { + data = { "reference": self.reference, "description": self.description, - "amount": self.amount.model_dump() if self.amount else None, - "allowPartial": self.allowPartial, - "shipping": self.shipping.model_dump() if self.shipping else None, + "amount": self.amount.to_dict(), + "allowPartial": self.allow_partial, "items": self.items_to_array(), - "recurring": self.recurring.model_dump() if self.recurring else None, - "discount": self.discount.model_dump() if self.discount else None, "subscribe": self.subscribe, - "agreement": self.agreement, - "agreementType": self.agreementType, "modifiers": self.modifiers_to_array(), "fields": self.fields_to_array(), } + + if self.shipping: + data["shipping"] = self.shipping.to_dict() + if self.recurring: + data["recurring"] = self.recurring.to_dict() + if self.discount: + data["discount"] = self.discount.to_dict() + if self.agreement: + data["agreement"] = self.agreement + if self.agreement_type: + data["agreementType"] = self.agreement_type + + return data diff --git a/checkout/entities/person.py b/checkout/entities/person.py index 9542a14..d697907 100644 --- a/checkout/entities/person.py +++ b/checkout/entities/person.py @@ -1,13 +1,13 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from checkout.entities.address import Address class Person(BaseModel): document: str = Field(default="", description="Document number of the person") - documentType: str = Field(default="", description="Type of document (e.g., ID, Passport)") + document_type: str = Field(default="", description="Type of document (e.g., ID, Passport)", alias="documentType") name: str = Field(default="", description="First name of the person") surname: str = Field(default="", description="Last name of the person") company: str = Field(default="", description="Company name if applicable") @@ -15,11 +15,13 @@ class Person(BaseModel): mobile: str = Field(default="", description="Mobile number of the person") address: Optional[Address] = Field(default=None, description="Address information") + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) + def is_business(self) -> bool: """ Check if the person is representing a business based on their document type. """ - return bool(self.documentType and self._business_document(self.documentType)) + return bool(self.document_type and self._business_document(self.document_type)) @staticmethod def _business_document(document_type: str) -> bool: @@ -34,4 +36,14 @@ def to_dict(self) -> dict: """ Convert the person object to a dictionary using the new `model_dump` method. """ - return self.model_dump(exclude_none=True) + + return { + "document": self.document, + "documentType": self.document_type, + "name": self.name, + "surname": self.surname, + "company": self.company, + "email": self.email, + "mobile": self.mobile, + "address": self.address.to_dict() if self.address else "", + } diff --git a/checkout/entities/recurring.py b/checkout/entities/recurring.py index ac6d611..f91bf07 100644 --- a/checkout/entities/recurring.py +++ b/checkout/entities/recurring.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Recurring(BaseModel): @@ -9,16 +9,24 @@ class Recurring(BaseModel): description="Frequency of the transaction (Y = annual, M = monthly, D = daily)", ) interval: int = Field(..., description="Interval between payments") - nextPayment: str = Field(default="", description="Next payment date") - maxPeriods: Optional[int] = Field( - default=None, - description="Maximum times the recurrence will happen, -1 if unlimited", + next_payment: str = Field(default="", description="Next payment date", alias="nextPayment") + max_periods: Optional[int] = Field( + default=-1, description="Maximum times the recurrence will happen, -1 if unlimited", alias="maxPeriods" ) - dueDate: str = Field(default="", description="Due date for the recurring charge") - notificationUrl: str = Field(default="", description="URL for sending notifications") + due_date: str = Field(default="", description="Due date for the recurring charge", alias="dueDate") + notification_url: str = Field(default="", description="URL for sending notifications", alias="notificationUrl") + + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) def to_dict(self) -> dict: """ Convert the Recurring object to a dictionary using the Pydantic `model_dump` method. """ - return self.model_dump() + return { + "periodicity": self.periodicity, + "interval": self.interval, + "nextPayment": self.next_payment, + "maxPeriods": self.max_periods, + "dueDate": self.due_date, + "notificationUrl": self.notification_url, + } diff --git a/checkout/entities/subscription.py b/checkout/entities/subscription.py index 20cf411..efd9f05 100644 --- a/checkout/entities/subscription.py +++ b/checkout/entities/subscription.py @@ -1,6 +1,6 @@ -from typing import List, Optional +from typing import List -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from checkout.entities.name_value_pair import NameValuePair from checkout.mixins.fields_mixin import FieldsMixin @@ -9,9 +9,9 @@ class Subscription(BaseModel, FieldsMixin): reference: str = Field(default="", description="Reference for the subscription") description: str = Field(default="", description="Description of the subscription") - customFields: Optional[List[NameValuePair]] = Field( - default_factory=lambda: [], description="Additional fields for the subscription" - ) + custom_fields: List[NameValuePair] = Field(default=[], description="Additional fields for the subscription") + + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) def to_dict(self) -> dict: """ @@ -19,4 +19,6 @@ def to_dict(self) -> dict: """ data = self.model_dump() data["fields"] = self.fields_to_array() + del data["custom_fields"] + return data diff --git a/checkout/messages/requests/collect.py b/checkout/messages/requests/collect.py index 58ed9a7..d2547ba 100644 --- a/checkout/messages/requests/collect.py +++ b/checkout/messages/requests/collect.py @@ -3,6 +3,7 @@ from pydantic import ConfigDict, Field from checkout.decorators.convert_to_boolean import convert_booleans_to_strings +from checkout.decorators.filter_empty_values import filter_empty_values from checkout.entities.instrument import Instrument from checkout.messages.requests.redirect import RedirectRequest @@ -10,12 +11,9 @@ class CollectRequest(RedirectRequest): model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) - instrument: Optional[Instrument] = Field(..., description="Instrument details") - return_url: str = Field(default="", alias="returnUrl", description="URL to return to after processing") - ip_address: str = Field(default="", alias="ipAddress", description="IP address of the user") - user_agent: str = Field(default="", alias="userAgent", description="User agent of the user's browser") + @filter_empty_values @convert_booleans_to_strings def to_dict(self) -> dict: """ diff --git a/checkout/messages/requests/redirect.py b/checkout/messages/requests/redirect.py index 4eeb0ab..36f98f0 100644 --- a/checkout/messages/requests/redirect.py +++ b/checkout/messages/requests/redirect.py @@ -1,14 +1,17 @@ -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field from checkout.decorators.convert_to_boolean import convert_booleans_to_strings +from checkout.decorators.filter_empty_values import filter_empty_values from checkout.entities.dispersion_payment import DispersionPayment +from checkout.entities.name_value_pair import NameValuePair from checkout.entities.person import Person from checkout.entities.subscription import Subscription +from checkout.mixins.fields_mixin import FieldsMixin -class RedirectRequest(BaseModel): +class RedirectRequest(BaseModel, FieldsMixin): locale: str = Field(default="es_CO", description="Locale of the request") payer: Optional[Person] = Field(default=None, description="Information about the payer") @@ -26,12 +29,31 @@ class RedirectRequest(BaseModel): no_buyer_fill: bool = Field( default=False, description="Whether to avoid pre-filling buyer data", alias="noBuyerFill" ) + custom_fields: List[NameValuePair] = Field(default=[], description="Additional fields for the redirect_request") model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) + @filter_empty_values @convert_booleans_to_strings def to_dict(self) -> dict: """ Convert the RedirectRequest object to a dictionary using Pydantic's dict method. """ - return self.model_dump(exclude_none=True, by_alias=True) + + return { + "locale": self.locale, + "payer": self.payer.to_dict() if self.payer else "", + "buyer": self.buyer.to_dict() if self.buyer else "", + "payment": self.payment.to_dict() if self.payment else "", + "subscription": self.subscription.to_dict() if self.subscription else "", + "returnUrl": self.return_url, + "payment_method": self.payment_method, + "cancelUrl": self.cancel_url, + "ipAddress": self.ip_address, + "userAgent": self.user_agent, + "expiration": self.expiration, + "captureAddress": self.capture_address, + "skipResult": self.skip_result, + "noBuyerFill": self.no_buyer_fill, + "fields": self.fields_to_array(), + } diff --git a/checkout/mixins/fields_mixin.py b/checkout/mixins/fields_mixin.py index 12c0934..ca57a67 100644 --- a/checkout/mixins/fields_mixin.py +++ b/checkout/mixins/fields_mixin.py @@ -4,13 +4,12 @@ class FieldsMixin: - fields: List[NameValuePair] = [] def get_fields(self) -> List[NameValuePair]: """ Return the list of NameValuePair objects. """ - return self.fields + return self.custom_fields def set_fields(self, fields_data: Union[List[Dict], Dict]) -> None: """ @@ -19,21 +18,26 @@ def set_fields(self, fields_data: Union[List[Dict], Dict]) -> None: if isinstance(fields_data, dict) and "item" in fields_data: fields_data = fields_data["item"] - self.fields = [] + if not hasattr(self, "custom_fields"): + self.custom_fields = [] + for nvp in fields_data: - self.fields.append(nvp if isinstance(nvp, NameValuePair) else NameValuePair(**nvp)) + self.custom_fields.append(nvp if isinstance(nvp, NameValuePair) else NameValuePair(**nvp)) def fields_to_array(self) -> List[Dict]: """ Convert the fields to a list of dictionaries. """ - return [field.to_dict() for field in self.fields if isinstance(field, NameValuePair)] + if not hasattr(self, "custom_fields"): + self.custom_fields = [] + + return [field.to_dict() for field in self.custom_fields if isinstance(field, NameValuePair)] def fields_to_key_value(self, nvps: Optional[List[NameValuePair]] = None) -> Dict[str, Union[str, list, dict]]: """ Convert the fields to a key-value pair dictionary. """ - nvps_data = nvps if nvps is not None else self.fields + nvps_data = nvps if nvps is not None else self.custom_fields return {field.keyword: field.value for field in nvps_data if isinstance(field, NameValuePair)} @@ -42,4 +46,4 @@ def add_field(self, nvp: Union[Dict, NameValuePair]) -> None: Add a new NameValuePair to the fields. """ name_value_pair = nvp if isinstance(nvp, NameValuePair) else NameValuePair(**nvp) - self.fields.append(name_value_pair) + self.custom_fields.append(name_value_pair) diff --git a/checkout/tests/unit/entities/test_dispersion_payment.py b/checkout/tests/unit/entities/test_dispersion_payment.py index 2a53082..6b19923 100644 --- a/checkout/tests/unit/entities/test_dispersion_payment.py +++ b/checkout/tests/unit/entities/test_dispersion_payment.py @@ -83,52 +83,27 @@ def test_dispersion_to_dict(self): } dispersion_payment = DispersionPayment(**data) - print(dispersion_payment.dispersion_to_dict()) expected_dispersion = [ { "reference": "DISP001", "description": "Split Payment 1", - "amount": { - "currency": "USD", - "total": 50.0, - "taxes": [], - "details": [], - "tip": None, - "insurance": None, - }, "allowPartial": False, - "shipping": None, "items": [], - "recurring": None, - "discount": None, "subscribe": False, - "agreement": None, - "agreementType": "", "modifiers": [], "fields": [], + "amount": {"currency": "USD", "total": 50.0, "taxes": [], "details": []}, }, { "reference": "DISP002", "description": "Split Payment 2", - "amount": { - "currency": "USD", - "total": 50.0, - "taxes": [], - "details": [], - "tip": None, - "insurance": None, - }, "allowPartial": False, - "shipping": None, "items": [], - "recurring": None, - "discount": None, "subscribe": False, - "agreement": None, - "agreementType": "", "modifiers": [], "fields": [], + "amount": {"currency": "USD", "total": 50.0, "taxes": [], "details": []}, }, ] @@ -161,62 +136,34 @@ def test_dispersion_payment_to_dict(self): expected_dict = { "reference": "REF001", "description": "Main Payment", - "amount": {"currency": "USD", "total": 100.0, "taxes": [], "details": [], "tip": None, "insurance": None}, "allowPartial": False, - "shipping": None, "items": [], - "recurring": None, - "discount": None, "subscribe": False, - "agreement": None, - "agreementType": "", "modifiers": [], "fields": [], + "amount": {"currency": "USD", "total": 100.0, "taxes": [], "details": []}, "dispersion": [ { "reference": "DISP001", "description": "Split Payment 1", - "amount": { - "currency": "USD", - "total": 50.0, - "taxes": [], - "details": [], - "tip": None, - "insurance": None, - }, "allowPartial": False, - "shipping": None, "items": [], - "recurring": None, - "discount": None, "subscribe": False, - "agreement": None, - "agreementType": "", "modifiers": [], "fields": [], + "amount": {"currency": "USD", "total": 50.0, "taxes": [], "details": []}, }, { "reference": "DISP002", "description": "Split Payment 2", - "amount": { - "currency": "USD", - "total": 50.0, - "taxes": [], - "details": [], - "tip": None, - "insurance": None, - }, "allowPartial": False, - "shipping": None, "items": [], - "recurring": None, - "discount": None, "subscribe": False, - "agreement": None, - "agreementType": "", "modifiers": [], "fields": [], + "amount": {"currency": "USD", "total": 50.0, "taxes": [], "details": []}, }, ], } + self.assertEqual(dispersion_payment.to_dict(), expected_dict) diff --git a/checkout/tests/unit/entities/test_name_value_pair.py b/checkout/tests/unit/entities/test_name_value_pair.py index 694d71a..462653e 100644 --- a/checkout/tests/unit/entities/test_name_value_pair.py +++ b/checkout/tests/unit/entities/test_name_value_pair.py @@ -10,10 +10,10 @@ def test_initialization(self): """ Test initialization of NameValuePair with valid data. """ - pair = NameValuePair(keyword="testKey", value="testValue", displayOn=DisplayOnEnum.BOTH) + pair = NameValuePair(keyword="testKey", value="testValue", display_on=DisplayOnEnum.BOTH) self.assertEqual(pair.keyword, "testKey") self.assertEqual(pair.value, "testValue") - self.assertEqual(pair.displayOn, DisplayOnEnum.BOTH) + self.assertEqual(pair.display_on, DisplayOnEnum.BOTH) def test_default_values(self): """ @@ -22,13 +22,13 @@ def test_default_values(self): pair = NameValuePair(keyword="defaultKey") self.assertEqual(pair.keyword, "defaultKey") self.assertIsNone(pair.value) - self.assertEqual(pair.displayOn, DisplayOnEnum.NONE) + self.assertEqual(pair.display_on, DisplayOnEnum.NONE) def test_to_dict(self): """ Test conversion of NameValuePair to a dictionary. """ - pair = NameValuePair(keyword="testKey", value={"key": "value"}, displayOn=DisplayOnEnum.RECEIPT) + pair = NameValuePair(keyword="testKey", value={"key": "value"}, display_on=DisplayOnEnum.RECEIPT) expected_dict = { "keyword": "testKey", "value": {"key": "value"}, @@ -40,7 +40,7 @@ def test_to_dict_exclude_none(self): """ Test dictionary conversion with exclusion of None values. """ - pair = NameValuePair(keyword="testKey", displayOn=DisplayOnEnum.PAYMENT) + pair = NameValuePair(keyword="testKey", display_on=DisplayOnEnum.PAYMENT) expected_dict = {"keyword": "testKey", "displayOn": "payment", "value": None} self.assertEqual(pair.to_dict(), expected_dict) diff --git a/checkout/tests/unit/entities/test_payment.py b/checkout/tests/unit/entities/test_payment.py index a439844..2cf9267 100644 --- a/checkout/tests/unit/entities/test_payment.py +++ b/checkout/tests/unit/entities/test_payment.py @@ -3,9 +3,11 @@ from checkout.entities.amount import Amount from checkout.entities.discount import Discount from checkout.entities.item import Item +from checkout.entities.name_value_pair import NameValuePair from checkout.entities.payment import Payment from checkout.entities.payment_modifier import PaymentModifier from checkout.entities.person import Person +from checkout.entities.recurring import Recurring class PaymentTest(unittest.TestCase): @@ -14,19 +16,20 @@ def test_payment_initialization(self): """ Test the initialization of a Payment object with default values. """ - payment = Payment(reference="REF001") + + payment = Payment(reference="REF001", amount={"currency": "COP", "total": 10000}) + assert payment.reference == "REF001" assert payment.description == "" - assert payment.amount is None - assert payment.allowPartial is False + assert payment.amount.to_dict() == {"currency": "COP", "total": 10000.0, "taxes": [], "details": []} + assert payment.allow_partial is False assert payment.shipping is None assert payment.items == [] assert payment.recurring is None - assert payment.payment is None assert payment.discount is None assert payment.subscribe is False assert payment.agreement is None - assert payment.agreementType == "" + assert payment.agreement_type == "" assert payment.modifiers == [] def test_payment_initialization_with_values(self): @@ -43,33 +46,55 @@ def test_payment_initialization_with_values(self): reference="REF001", description="Test Payment", amount=amount, - allowPartial=True, + allow_partial=True, shipping=shipping, items=items, discount=discount, subscribe=True, agreement=123, - agreementType="TYPE001", + agreement_type="TYPE001", modifiers=modifiers, ) assert payment.reference == "REF001" assert payment.description == "Test Payment" assert payment.amount == amount - assert payment.allowPartial is True + assert payment.allow_partial is True assert payment.shipping == shipping assert payment.items == items assert payment.discount == discount assert payment.subscribe is True assert payment.agreement == 123 - assert payment.agreementType == "TYPE001" + assert payment.agreement_type == "TYPE001" assert payment.modifiers == modifiers + def test_payment_initialization_with_recurring(self): + """ + Test the initialization of a Payment object with dispersion values. + """ + + recurring_data = { + "periodicity": "D", + "interval": 1, + "nextPayment": "2024-12-31", + "maxPeriods": 2, + "dueDate": "2025-01-01", + "notificationUrl": "https://merchant.com/notification", + } + payment = Payment( + reference="REF001", + description="Test Payment", + amount=Amount(currency="USD", total=100.0), + recurring=Recurring(**recurring_data), + ) + + assert payment.to_dict()["recurring"] == recurring_data + def test_set_items(self): """ Test setting items in the Payment object. """ - payment = Payment(reference="REF001") + payment = Payment(reference="REF001", amount={"currency": "COP", "total": 10000}) items = [{"sku": "ITEM001", "name": "Test Item", "qty": "1", "price": "50.0"}] payment.set_items(items) @@ -80,7 +105,7 @@ def test_items_to_array(self): """ Test converting items to an array of dictionaries. """ - payment = Payment(reference="REF001") + payment = Payment(reference="REF001", amount={"currency": "COP", "total": 10000}) items = [{"sku": "ITEM001", "name": "Test Item", "qty": "1", "price": "50.0"}] payment.set_items(items) @@ -92,7 +117,7 @@ def test_set_modifiers(self): """ Test setting modifiers in the Payment object. """ - payment = Payment(reference="REF001") + payment = Payment(reference="REF001", amount={"currency": "COP", "total": 10000}) modifiers = [{"type": "CUSTOM_TYPE", "code": "MOD123"}] payment.set_modifiers(modifiers) @@ -103,7 +128,7 @@ def test_modifiers_to_array(self): """ Test converting modifiers to an array of dictionaries. """ - payment = Payment(reference="REF001") + payment = Payment(reference="REF001", amount={"currency": "COP", "total": 10000}) modifiers = [{"type": "CUSTOM_TYPE", "code": "MOD123"}] payment.set_modifiers(modifiers) @@ -128,13 +153,28 @@ def test_to_dict(self): shipping=shipping, items=items, discount=discount, + agreement=123, + agreement_type="TYPE001", modifiers=modifiers, + custom_fields=[NameValuePair(keyword="field1", value="value1")], ) + payment.set_fields([NameValuePair(keyword="field2", value="value2")]) payment_dict = payment.to_dict() + fields = payment.get_fields() + assert payment_dict["reference"] == "REF001" assert payment_dict["amount"]["currency"] == "USD" assert payment_dict["shipping"]["name"] == "John" assert payment_dict["items"][0]["sku"] == "ITEM001" assert payment_dict["discount"]["code"] == "DISC10" assert payment_dict["modifiers"][0]["type"] == "FEDERAL_GOVERNMENT" + assert payment_dict["agreement"] == 123 + assert payment_dict["agreementType"] == "TYPE001" + assert payment.fields_to_key_value() == {"field1": "value1", "field2": "value2"} + assert fields[0].display_on == "none" + assert fields[0].keyword == "field1" + assert fields[0].value == "value1" + assert fields[1].display_on == "none" + assert fields[1].keyword == "field2" + assert fields[1].value == "value2" diff --git a/checkout/tests/unit/entities/test_person.py b/checkout/tests/unit/entities/test_person.py index 0d1ed12..b9130c1 100644 --- a/checkout/tests/unit/entities/test_person.py +++ b/checkout/tests/unit/entities/test_person.py @@ -13,7 +13,7 @@ def test_person_initialization_with_defaults(self): person = Person() assert person.document == "" - assert person.documentType == "" + assert person.document_type == "" assert person.name == "" assert person.surname == "" assert person.company == "" @@ -31,7 +31,7 @@ def test_person_initialization_with_values(self): person = Person( document="123456789", - documentType="TIN", + document_type="TIN", name="John", surname="Doe", company="TestCorp", @@ -41,7 +41,7 @@ def test_person_initialization_with_values(self): ) assert person.document == "123456789" - assert person.documentType == "TIN" + assert person.document_type == "TIN" assert person.name == "John" assert person.surname == "Doe" assert person.company == "TestCorp" @@ -69,7 +69,7 @@ def test_person_to_dict(self): person = Person( document="123456789", - documentType="TIN", + document_type="TIN", name="John", surname="Doe", company="TestCorp", @@ -95,7 +95,7 @@ def test_person_to_dict_without_address(self): """ person = Person( document="123456789", - documentType="TIN", + document_type="TIN", name="John", surname="Doe", company="TestCorp", @@ -104,7 +104,6 @@ def test_person_to_dict_without_address(self): ) person_dict = person.to_dict() - assert person_dict["document"] == "123456789" assert person_dict["documentType"] == "TIN" assert person_dict["name"] == "John" @@ -112,4 +111,4 @@ def test_person_to_dict_without_address(self): assert person_dict["company"] == "TestCorp" assert person_dict["email"] == "john.doe@example.com" assert person_dict["mobile"] == "1234567890" - assert "address" not in person_dict + assert person_dict["address"] == "" diff --git a/checkout/tests/unit/entities/test_recurring.py b/checkout/tests/unit/entities/test_recurring.py index 574a481..28786a4 100644 --- a/checkout/tests/unit/entities/test_recurring.py +++ b/checkout/tests/unit/entities/test_recurring.py @@ -12,15 +12,15 @@ def test_recurring_initialization_with_defaults(self): recurring = Recurring( periodicity="M", interval=1, - nextPayment="2024-01-01", + next_payment="2024-01-01", ) assert recurring.periodicity == "M" assert recurring.interval == 1 - assert recurring.nextPayment == "2024-01-01" - assert recurring.maxPeriods is None - assert recurring.dueDate == "" - assert recurring.notificationUrl == "" + assert recurring.next_payment == "2024-01-01" + assert recurring.max_periods == -1 + assert recurring.due_date == "" + assert recurring.notification_url == "" def test_recurring_initialization_with_values(self): """ @@ -29,18 +29,18 @@ def test_recurring_initialization_with_values(self): recurring = Recurring( periodicity="M", interval=1, - nextPayment="2024-01-01", - maxPeriods=12, - dueDate="2024-12-31", - notificationUrl="https://example.com/notify", + next_payment="2024-01-01", + max_periods=12, + due_date="2024-12-31", + notification_url="https://example.com/notify", ) assert recurring.periodicity == "M" assert recurring.interval == 1 - assert recurring.nextPayment == "2024-01-01" - assert recurring.maxPeriods == 12 - assert recurring.dueDate == "2024-12-31" - assert recurring.notificationUrl == "https://example.com/notify" + assert recurring.next_payment == "2024-01-01" + assert recurring.max_periods == 12 + assert recurring.due_date == "2024-12-31" + assert recurring.notification_url == "https://example.com/notify" def test_recurring_to_dict(self): """ @@ -49,10 +49,10 @@ def test_recurring_to_dict(self): recurring = Recurring( periodicity="M", interval=1, - nextPayment="2024-01-01", - maxPeriods=12, - dueDate="2024-12-31", - notificationUrl="https://example.com/notify", + next_payment="2024-01-01", + max_periods=12, + due_date="2024-12-31", + notification_url="https://example.com/notify", ) recurring_dict = recurring.to_dict() @@ -75,7 +75,7 @@ def test_recurring_to_dict_exclude_defaults(self): recurring = Recurring( periodicity="D", interval=7, - nextPayment="2024-01-01", + next_payment="2024-01-01", ) recurring_dict = recurring.to_dict() @@ -84,7 +84,7 @@ def test_recurring_to_dict_exclude_defaults(self): "periodicity": "D", "interval": 7, "nextPayment": "2024-01-01", - "maxPeriods": None, + "maxPeriods": -1, "dueDate": "", "notificationUrl": "", } diff --git a/checkout/tests/unit/entities/test_subscription.py b/checkout/tests/unit/entities/test_subscription.py index d2d3dc1..0557857 100644 --- a/checkout/tests/unit/entities/test_subscription.py +++ b/checkout/tests/unit/entities/test_subscription.py @@ -14,7 +14,7 @@ def test_initialization(self): subscription = Subscription( reference="SUB123", description="Test subscription", - customFields=[ + custom_fields=[ NameValuePair(keyword="field1", value="value1"), NameValuePair(keyword="field2", value="value2"), ], @@ -22,9 +22,9 @@ def test_initialization(self): self.assertEqual(subscription.reference, "SUB123") self.assertEqual(subscription.description, "Test subscription") - self.assertEqual(len(subscription.customFields), 2) - self.assertEqual(subscription.customFields[0].keyword, "field1") - self.assertEqual(subscription.customFields[0].value, "value1") + self.assertEqual(len(subscription.custom_fields), 2) + self.assertEqual(subscription.custom_fields[0].keyword, "field1") + self.assertEqual(subscription.custom_fields[0].value, "value1") def test_to_dict(self): """ @@ -33,7 +33,7 @@ def test_to_dict(self): subscription = Subscription( reference="SUB123", description="Test subscription", - customFields=[ + custom_fields=[ NameValuePair(keyword="field1", value="value1"), NameValuePair(keyword="field2", value="value2"), ], @@ -42,11 +42,10 @@ def test_to_dict(self): expected_dict = { "reference": "SUB123", "description": "Test subscription", - "customFields": [ + "fields": [ {"keyword": "field1", "value": "value1", "displayOn": DisplayOnEnum.NONE}, {"keyword": "field2", "value": "value2", "displayOn": DisplayOnEnum.NONE}, ], - "fields": [], } self.assertEqual(subscription.to_dict(), expected_dict) @@ -58,7 +57,7 @@ def test_empty_subscription(self): subscription = Subscription() self.assertEqual(subscription.reference, "") self.assertEqual(subscription.description, "") - self.assertEqual(subscription.customFields, []) + self.assertEqual(subscription.custom_fields, []) self.assertEqual(subscription.fields_to_array(), []) def test_fields_to_array(self): diff --git a/checkout/tests/unit/messages/requests/test_collect.py b/checkout/tests/unit/messages/requests/test_collect.py index a446cd6..73dfad5 100644 --- a/checkout/tests/unit/messages/requests/test_collect.py +++ b/checkout/tests/unit/messages/requests/test_collect.py @@ -70,7 +70,6 @@ def test_to_dict_without_token(self): result = collect_request.to_dict() self.assertIn("instrument", result) - self.assertIsNone(result["instrument"]["token"]) self.assertEqual(result["instrument"]["pin"], "5678") self.assertEqual(result["instrument"]["password"], "no_token") self.assertEqual("es_CO", result["locale"]) diff --git a/checkout/tests/unit/messages/requests/test_redirect.py b/checkout/tests/unit/messages/requests/test_redirect.py index 3cd73c3..29004e9 100644 --- a/checkout/tests/unit/messages/requests/test_redirect.py +++ b/checkout/tests/unit/messages/requests/test_redirect.py @@ -1,5 +1,6 @@ import unittest +from checkout.entities.name_value_pair import NameValuePair from checkout.messages.requests.redirect import RedirectRequest @@ -24,7 +25,7 @@ def test_initialization_with_all_fields(self): "locale": "en_US", "payer": {"document": "123456789", "name": "John", "surname": "Doe"}, "buyer": {"document": "987654321", "name": "Jane", "surname": "Doe"}, - "payment": {"reference": "TEST_REF"}, + "payment": {"reference": "TEST_REF", "amount": {"currency": "COP", "total": 10000}}, "subscription": {"reference": "SUB123", "description": "Test Subscription"}, "returnUrl": "https://example.com/return", "paymentMethod": "credit_card", @@ -33,8 +34,8 @@ def test_initialization_with_all_fields(self): "userAgent": "Test User Agent", "expiration": "2023-12-31T23:59:59Z", "captureAddress": "true", - "skipResult": "true", - "noBuyerFill": "true", + "skipResult": True, + "noBuyerFill": True, } redirect_request = RedirectRequest(**data) @@ -70,19 +71,44 @@ def test_to_dict(self): "ipAddress": "192.168.1.1", "userAgent": "Test User Agent", "captureAddress": True, + "cancel_url": "https://cancel.com/return", + "payment": { + "reference": "REF_123", + "description": "DES_123", + "amount": {"total": 1000, "currency": "COP"}, + "custom_fields": [NameValuePair(keyword="payment_field", value="payment")], + }, + "custom_fields": [ + NameValuePair(keyword="field1", value="value1"), + NameValuePair(keyword="field2", value="value2"), + ], } redirect_request = RedirectRequest(**data) result = redirect_request.to_dict() expected = { "locale": "es_CO", + "payment": { + "reference": "REF_123", + "description": "DES_123", + "amount": {"currency": "COP", "total": 1000.0}, + "allowPartial": "false", + "subscribe": "false", + "fields": [{"keyword": "payment_field", "value": "payment", "displayOn": "none"}], + }, "returnUrl": "https://example.com/return", + "cancelUrl": "https://cancel.com/return", "ipAddress": "192.168.1.1", "userAgent": "Test User Agent", "captureAddress": "true", "skipResult": "false", "noBuyerFill": "false", + "fields": [ + {"keyword": "field1", "value": "value1", "displayOn": "none"}, + {"keyword": "field2", "value": "value2", "displayOn": "none"}, + ], } + self.assertEqual(result, expected) def test_optional_fields_defaults(self): diff --git a/checkout/tests/unit/mixins/test_fields_mixin.py b/checkout/tests/unit/mixins/test_fields_mixin.py index 6a3aead..943ae96 100644 --- a/checkout/tests/unit/mixins/test_fields_mixin.py +++ b/checkout/tests/unit/mixins/test_fields_mixin.py @@ -1,22 +1,10 @@ import unittest -from checkout.entities.name_value_pair import NameValuePair from checkout.mixins.fields_mixin import FieldsMixin class FieldsMixinTest(unittest.TestCase): - def test_get_fields(self): - """ - Test retrieving the fields list. - """ - mixin = FieldsMixin() - mixin.fields = [NameValuePair(keyword="key1", value="value1")] - fields = mixin.get_fields() - self.assertEqual(len(fields), 1) - self.assertEqual(fields[0].keyword, "key1") - self.assertEqual(fields[0].value, "value1") - def test_set_fields_with_dict(self): """ Test setting fields with a dictionary containing an 'item' key. @@ -24,91 +12,6 @@ def test_set_fields_with_dict(self): mixin = FieldsMixin() fields_data = {"item": [{"keyword": "key1", "value": "value1"}, {"keyword": "key2", "value": "value2"}]} mixin.set_fields(fields_data) - self.assertEqual(len(mixin.fields), 2) - self.assertEqual(mixin.fields[0].keyword, "key1") - self.assertEqual(mixin.fields[1].keyword, "key2") - - def test_set_fields_with_list(self): - """ - Test setting fields with a list of dictionaries. - """ - mixin = FieldsMixin() - fields_data = [{"keyword": "key1", "value": "value1"}, {"keyword": "key2", "value": "value2"}] - mixin.set_fields(fields_data) - self.assertEqual(len(mixin.fields), 2) - self.assertEqual(mixin.fields[0].keyword, "key1") - self.assertEqual(mixin.fields[1].value, "value2") - - def test_fields_to_array(self): - """ - Test converting fields to a list of dictionaries. - """ - mixin = FieldsMixin() - mixin.fields = [ - NameValuePair(keyword="key1", value="value1"), - NameValuePair(keyword="key2", value="value2"), - ] - result = mixin.fields_to_array() - expected = [ - {"keyword": "key1", "value": "value1", "displayOn": "none"}, - {"keyword": "key2", "value": "value2", "displayOn": "none"}, - ] - self.assertEqual(result, expected) - - def test_fields_to_key_value(self): - """ - Test converting fields to a key-value dictionary. - """ - mixin = FieldsMixin() - mixin.fields = [ - NameValuePair(keyword="key1", value="value1"), - NameValuePair(keyword="key2", value="value2"), - ] - result = mixin.fields_to_key_value() - expected = {"key1": "value1", "key2": "value2"} - self.assertEqual(result, expected) - - def test_add_field_with_dict(self): - """ - Test adding a field using a dictionary. - """ - mixin = FieldsMixin() - field_data = {"keyword": "key1", "value": "value1"} - mixin.add_field(field_data) - self.assertEqual(len(mixin.fields), 1) - self.assertEqual(mixin.fields[0].keyword, "key1") - self.assertEqual(mixin.fields[0].value, "value1") - - def test_add_field_with_name_value_pair(self): - """ - Test adding a field using a NameValuePair instance. - """ - mixin = FieldsMixin() - nvp = NameValuePair(keyword="key1", value="value1") - mixin.add_field(nvp) - self.assertEqual(len(mixin.fields), 2) - self.assertEqual(mixin.fields[0].keyword, "key1") - self.assertEqual(mixin.fields[0].value, "value1") - - def test_set_fields_with_item_key(self): - """ - Test setting fields when the input is a dictionary with an 'item' key. - """ - fields_data = {"item": [{"keyword": "key1", "value": "value1"}, {"keyword": "key2", "value": "value2"}]} - - mixin = FieldsMixin() - mixin.set_fields(fields_data) - self.assertEqual(len(mixin.fields), 2) - - self.assertEqual(mixin.fields[0].keyword, "key1") - self.assertEqual(mixin.fields[1].keyword, "key2") - - def test_fields_to_key_value_with_non_name_value_pair(self): - """ - Test fields_to_key_value skips non-NameValuePair objects. - """ - mixin = FieldsMixin() - mixin.fields = [NameValuePair(keyword="key1", value="value1"), "invalid_field"] - result = mixin.fields_to_key_value() - expected = {"key1": "value1"} - self.assertEqual(result, expected) + self.assertEqual(len(mixin.custom_fields), 2) + self.assertEqual(mixin.custom_fields[0].keyword, "key1") + self.assertEqual(mixin.custom_fields[1].keyword, "key2") From 802112260051587322fc4eb775793f661ded8501 Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 09:40:09 -0500 Subject: [PATCH 02/10] Update test_information_response.py --- .../tests/unit/messages/responses/test_information_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkout/tests/unit/messages/responses/test_information_response.py b/checkout/tests/unit/messages/responses/test_information_response.py index c3481e3..09034e6 100644 --- a/checkout/tests/unit/messages/responses/test_information_response.py +++ b/checkout/tests/unit/messages/responses/test_information_response.py @@ -190,5 +190,5 @@ def test_last_transaction_no_approved(self): Transaction(reference="TX002", status=Status(status=StatusEnum.FAILED, reason="Failed")), ], ) - print(information.last_transaction(approved=True)) + self.assertIsNone(information.last_transaction(approved=True)) From d7c1acf4e9e8ab9fc09a5f5c9fe86a31d1efeb6c Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 10:00:09 -0500 Subject: [PATCH 03/10] Update CHANGELOG.md --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3439a0d..b925e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- added: adds authentication module +### Added + +- Initial release of Checkout P2P version 1.0.0. +- Support for **query**, **single payments**, **subscriptions**, **payments using subscription tokens** and **reverse**. +- Added Pydantic-based validation for request and response models. +- Decorators to format booleans and clean dictionaries for API compatibility. From c87c89107b49c2e23efee6ec7d79827c5f1c077f Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 10:27:23 -0500 Subject: [PATCH 04/10] refactor: improves token entity --- checkout/entities/subscription_information.py | 12 +------- checkout/entities/token.py | 26 +++++++++++----- .../entities/test_subscription_information.py | 2 +- checkout/tests/unit/entities/test_token.py | 30 +++++++++---------- 4 files changed, 36 insertions(+), 34 deletions(-) diff --git a/checkout/entities/subscription_information.py b/checkout/entities/subscription_information.py index 524c493..72bf16c 100644 --- a/checkout/entities/subscription_information.py +++ b/checkout/entities/subscription_information.py @@ -68,17 +68,7 @@ def parse_instrument(self) -> Optional[Union[Account, Token]]: data[nvp.keyword] = nvp.value if self.type == "token": - return Token( - token=data.get("token", ""), - subtoken=data.get("subtoken", ""), - franchise=data.get("franchise", ""), - franchiseName=data.get("franchiseName", ""), - issuerName=data.get("issuerName", ""), - lastDigits=data.get("lastDigits", ""), - validUntil=data.get("validUntil", ""), - cvv=data.get("cvv", ""), - installments=data.get("installments", 0), - ) + return Token(**data) elif self.type == "account": return Account( diff --git a/checkout/entities/token.py b/checkout/entities/token.py index 9e3f4d1..a258597 100644 --- a/checkout/entities/token.py +++ b/checkout/entities/token.py @@ -1,25 +1,27 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Token(BaseModel): token: str = Field(default="", description="Unique token identifier") subtoken: str = Field(default="", description="Secondary token identifier") franchise: str = Field(default="", description="Franchise associated with the token") - franchiseName: str = Field(default="", description="Name of the franchise") - issuerName: str = Field(default="", description="Name of the issuer") - lastDigits: str = Field(default="", description="Last digits of the card/token") - validUntil: str = Field(default="", description="Expiration date in ISO format") + franchise_name: str = Field(default="", description="Name of the franchise", alias="franchiseName") + issuer_name: str = Field(default="", description="Name of the issuer", alias="issuerName") + last_digits: str = Field(default="", description="Last digits of the card/token", alias="lastDigits") + valid_until: str = Field(default="", description="Expiration date in ISO format", alias="validUntil") cvv: str = Field(default="", description="CVV associated with the token") installments: int = Field(default=0, description="Number of installments") + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) + def expiration(self) -> str: """ Convert valid_until to 'mm/yy' format for expiration date. """ try: - expiration_date = datetime.strptime(self.validUntil, "%Y-%m-%d") + expiration_date = datetime.strptime(self.valid_until, "%Y-%m-%d") return expiration_date.strftime("%m/%y") except ValueError: return "Invalid date" @@ -28,4 +30,14 @@ def to_dict(self) -> dict: """ Convert the Token object to a dictionary using the Pydantic model_dump method. """ - return self.model_dump() + return { + "token": self.token, + "subtoken": self.subtoken, + "franchise": self.franchise, + "franchiseName": self.franchise_name, + "issuerName": self.issuer_name, + "lastDigits": self.last_digits, + "validUntil": self.valid_until, + "cvv": self.cvv, + "installments": self.installments, + } diff --git a/checkout/tests/unit/entities/test_subscription_information.py b/checkout/tests/unit/entities/test_subscription_information.py index 636b05e..50492ac 100644 --- a/checkout/tests/unit/entities/test_subscription_information.py +++ b/checkout/tests/unit/entities/test_subscription_information.py @@ -79,7 +79,7 @@ def test_parse_instrument_as_token(self): self.assertEqual(result.token, "12345") self.assertEqual(result.subtoken, "54321") self.assertEqual(result.franchise, "visa") - self.assertEqual(result.validUntil, "2025-12-31") + self.assertEqual(result.valid_until, "2025-12-31") def test_parse_instrument_as_account(self): """ diff --git a/checkout/tests/unit/entities/test_token.py b/checkout/tests/unit/entities/test_token.py index 0e13c33..494ef53 100644 --- a/checkout/tests/unit/entities/test_token.py +++ b/checkout/tests/unit/entities/test_token.py @@ -13,10 +13,10 @@ def test_initialization(self): token="12345", subtoken="67890", franchise="Visa", - franchiseName="Visa International", - issuerName="Bank of Test", - lastDigits="1234", - validUntil="2025-12-31", + franchise_name="Visa International", + issuer_name="Bank of Test", + last_digits="1234", + valid_until="2025-12-31", cvv="123", installments=12, ) @@ -24,10 +24,10 @@ def test_initialization(self): self.assertEqual(token.token, "12345") self.assertEqual(token.subtoken, "67890") self.assertEqual(token.franchise, "Visa") - self.assertEqual(token.franchiseName, "Visa International") - self.assertEqual(token.issuerName, "Bank of Test") - self.assertEqual(token.lastDigits, "1234") - self.assertEqual(token.validUntil, "2025-12-31") + self.assertEqual(token.franchise_name, "Visa International") + self.assertEqual(token.issuer_name, "Bank of Test") + self.assertEqual(token.last_digits, "1234") + self.assertEqual(token.valid_until, "2025-12-31") self.assertEqual(token.cvv, "123") self.assertEqual(token.installments, 12) @@ -35,21 +35,21 @@ def test_expiration_valid_date(self): """ Test the expiration method with a valid date. """ - token = Token(validUntil="2025-12-31") + token = Token(valid_until="2025-12-31") self.assertEqual(token.expiration(), "12/25") def test_expiration_invalid_date(self): """ Test the expiration method with an invalid date. """ - token = Token(validUntil="invalid-date") + token = Token(valid_until="invalid-date") self.assertEqual(token.expiration(), "Invalid date") def test_expiration_empty_date(self): """ Test the expiration method with an empty date. """ - token = Token(validUntil="") + token = Token(valid_until="") self.assertEqual(token.expiration(), "Invalid date") def test_to_dict(self): @@ -60,10 +60,10 @@ def test_to_dict(self): token="12345", subtoken="67890", franchise="Visa", - franchiseName="Visa International", - issuerName="Bank of Test", - lastDigits="1234", - validUntil="2025-12-31", + franchise_name="Visa International", + issuer_name="Bank of Test", + last_digits="1234", + valid_until="2025-12-31", cvv="123", installments=12, ) From 0201c320ecbd59d28724bea8749ab7a7681875da Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 10:59:40 -0500 Subject: [PATCH 05/10] feat: adds invalidateTokenRequest --- .../messages/requests/invalidate_token.py | 15 ++++++++++ .../requests/test_invalidate_token.py | 30 +++++++++++++++++++ test_invalidate_token_test.py | 0 3 files changed, 45 insertions(+) create mode 100644 checkout/messages/requests/invalidate_token.py create mode 100644 checkout/tests/unit/messages/requests/test_invalidate_token.py create mode 100644 test_invalidate_token_test.py diff --git a/checkout/messages/requests/invalidate_token.py b/checkout/messages/requests/invalidate_token.py new file mode 100644 index 0000000..452a540 --- /dev/null +++ b/checkout/messages/requests/invalidate_token.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field + +from checkout.decorators.convert_to_boolean import convert_booleans_to_strings +from checkout.decorators.filter_empty_values import filter_empty_values +from checkout.entities.instrument import Instrument + + +class InvalidateToKenRequest(BaseModel): + locale: str = Field(default="es_CO", description="Locale of the request") + instrument: Instrument = Field(..., description="Instrument details") + + @filter_empty_values + @convert_booleans_to_strings + def to_dic(self) -> dict: + return {"locale": self.locale, "instrument": self.instrument.to_dict()} diff --git a/checkout/tests/unit/messages/requests/test_invalidate_token.py b/checkout/tests/unit/messages/requests/test_invalidate_token.py new file mode 100644 index 0000000..a1ffbc0 --- /dev/null +++ b/checkout/tests/unit/messages/requests/test_invalidate_token.py @@ -0,0 +1,30 @@ +import unittest + +from checkout.entities.instrument import Instrument +from checkout.messages.requests.invalidate_token import InvalidateToKenRequest + + +class InvalidateTokenRequestTest(unittest.TestCase): + def test_to_dic_valid(self): + """Test to_dic returns the correct dictionary format.""" + instrument = Instrument(**{"token": {"token": "test_token", "subtoken": "test_subtoken"}}) + request = InvalidateToKenRequest(locale="en_US", instrument=instrument) + + expected_output = { + "locale": "en_US", + "instrument": {"token": {"token": "test_token", "subtoken": "test_subtoken"}}, + } + + self.assertEqual(request.to_dic(), expected_output) + + def test_to_dic_default_locale(self): + """Test to_dic with default locale value.""" + instrument = Instrument(**{"token": {"token": "test_token", "subtoken": "test_subtoken"}}) + request = InvalidateToKenRequest(instrument=instrument) + + expected_output = { + "locale": "es_CO", + "instrument": {"token": {"token": "test_token", "subtoken": "test_subtoken"}}, + } + + self.assertEqual(request.to_dic(), expected_output) diff --git a/test_invalidate_token_test.py b/test_invalidate_token_test.py new file mode 100644 index 0000000..e69de29 From 6f65ea9a923dcf09141317f515a190e56dd2a74e Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 10:59:59 -0500 Subject: [PATCH 06/10] feat: adds invalidateToken endpoint --- checkout/clients/rest_client.py | 9 +++++++++ checkout/contracts/carrier.py | 7 +++++++ checkout/entities/token.py | 3 ++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/checkout/clients/rest_client.py b/checkout/clients/rest_client.py index 6b9230d..790b7e0 100644 --- a/checkout/clients/rest_client.py +++ b/checkout/clients/rest_client.py @@ -1,7 +1,9 @@ from typing import Dict from checkout.contracts.carrier import Carrier +from checkout.entities.instrument import Instrument from checkout.entities.settings import Settings +from checkout.entities.status import Status from checkout.messages.requests.collect import CollectRequest from checkout.messages.requests.redirect import RedirectRequest from checkout.messages.responses.information import InformationResponse @@ -56,3 +58,10 @@ def reverse(self, transaction_id: str) -> ReverseResponse: """ result = self._post("api/reverse", {"internalReference": transaction_id}) return ReverseResponse(**result) + + def invalidateToken(self, instrument: Instrument) -> Status: + """ + Invalidate a token. + """ + result = self._post("/api/instrument/invalidate", {"instrument": instrument.to_dict()}) + return Status(**result) diff --git a/checkout/contracts/carrier.py b/checkout/contracts/carrier.py index 4dfd1fa..93302b1 100644 --- a/checkout/contracts/carrier.py +++ b/checkout/contracts/carrier.py @@ -1,5 +1,7 @@ from typing import Protocol +from checkout.entities.instrument import Instrument +from checkout.entities.status import Status from checkout.messages.requests.collect import CollectRequest from checkout.messages.requests.redirect import RedirectRequest from checkout.messages.responses.information import InformationResponse @@ -27,3 +29,8 @@ def reverse(self, transaction_id: str) -> ReverseResponse: """ Reverse a transaction by its ID. """ + + def invalidateToken(self, instrument: Instrument) -> Status: + """ + invalidate a token. + """ diff --git a/checkout/entities/token.py b/checkout/entities/token.py index a258597..f5debc5 100644 --- a/checkout/entities/token.py +++ b/checkout/entities/token.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -12,7 +13,7 @@ class Token(BaseModel): last_digits: str = Field(default="", description="Last digits of the card/token", alias="lastDigits") valid_until: str = Field(default="", description="Expiration date in ISO format", alias="validUntil") cvv: str = Field(default="", description="CVV associated with the token") - installments: int = Field(default=0, description="Number of installments") + installments: Optional[int] = Field(default=None, description="Number of installments") model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) From b53ca81e80564f0de0d337007b2fd172beb81f36 Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 11:38:31 -0500 Subject: [PATCH 07/10] feat: adds invaliteToken endpoint --- CHANGELOG.md | 2 +- checkout/checkout.py | 18 ++++++++-- checkout/clients/rest_client.py | 9 ++--- checkout/contracts/carrier.py | 4 +-- checkout/entities/status.py | 15 ++++++++- checkout/tests/feature/test_checkout.py | 33 +++++++++++++++++++ ..._token_response_fails_token_not_valid.json | 8 +++++ .../invalidate_token_response_successful.json | 8 +++++ 8 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 checkout/tests/mocks/responses/invalidate_token_response_fails_token_not_valid.json create mode 100644 checkout/tests/mocks/responses/invalidate_token_response_successful.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b925e35..58fba7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of Checkout P2P version 1.0.0. -- Support for **query**, **single payments**, **subscriptions**, **payments using subscription tokens** and **reverse**. +- Support for **query**, **single payments**, **subscriptions**, **payments using subscription tokens**, **Invalidate token** and **reverse**. - Added Pydantic-based validation for request and response models. - Decorators to format booleans and clean dictionaries for API compatibility. diff --git a/checkout/checkout.py b/checkout/checkout.py index 13b5b78..3516580 100644 --- a/checkout/checkout.py +++ b/checkout/checkout.py @@ -2,14 +2,16 @@ from checkout.contracts.carrier import Carrier from checkout.entities.settings import Settings +from checkout.entities.status import Status from checkout.exceptions.checkout_exception import CheckoutException from checkout.messages.requests.collect import CollectRequest +from checkout.messages.requests.invalidate_token import InvalidateToKenRequest from checkout.messages.requests.redirect import RedirectRequest from checkout.messages.responses.information import InformationResponse from checkout.messages.responses.redirect import RedirectResponse from checkout.messages.responses.reverse import ReverseResponse -T = TypeVar("T", RedirectRequest, CollectRequest) +T = TypeVar("T", RedirectRequest, CollectRequest, InvalidateToKenRequest) class Checkout: @@ -26,7 +28,9 @@ def __init__(self, data: Dict[str, Any]) -> None: self.settings: Settings = Settings(**data) self.logger = self.settings.logger() - def _validate_request(self, request: Union[RedirectRequest, CollectRequest, Dict], expected_class: Type[T]) -> T: + def _validate_request( + self, request: Union[RedirectRequest, CollectRequest, InvalidateToKenRequest, Dict], expected_class: Type[T] + ) -> T: """ Validate the request object and convert it to the expected class if necessary. @@ -98,3 +102,13 @@ def reverse(self, internal_reference: str) -> ReverseResponse: """ self.logger.info(f"Reversing transaction with reference: {internal_reference}.") return self.carrier.reverse(internal_reference) + + def invalidateToken(self, invalidate_token_request: Union[InvalidateToKenRequest, Dict]) -> Status: + """ + Reverse a transaction. + + :param invalidate_token_request: InvalidateToKenRequest instance or dictionary with request data. + :return: Status object. + """ + invalidate_token_request = self._validate_request(invalidate_token_request, InvalidateToKenRequest) + return self.carrier.invalidateToken(invalidate_token_request) diff --git a/checkout/clients/rest_client.py b/checkout/clients/rest_client.py index 790b7e0..0aef6ba 100644 --- a/checkout/clients/rest_client.py +++ b/checkout/clients/rest_client.py @@ -1,10 +1,10 @@ from typing import Dict from checkout.contracts.carrier import Carrier -from checkout.entities.instrument import Instrument from checkout.entities.settings import Settings from checkout.entities.status import Status from checkout.messages.requests.collect import CollectRequest +from checkout.messages.requests.invalidate_token import InvalidateToKenRequest from checkout.messages.requests.redirect import RedirectRequest from checkout.messages.responses.information import InformationResponse from checkout.messages.responses.redirect import RedirectResponse @@ -59,9 +59,10 @@ def reverse(self, transaction_id: str) -> ReverseResponse: result = self._post("api/reverse", {"internalReference": transaction_id}) return ReverseResponse(**result) - def invalidateToken(self, instrument: Instrument) -> Status: + def invalidateToken(self, invalidate_token_request: InvalidateToKenRequest) -> Status: """ Invalidate a token. """ - result = self._post("/api/instrument/invalidate", {"instrument": instrument.to_dict()}) - return Status(**result) + result = self._post("/api/instrument/invalidate", invalidate_token_request.to_dic()) + + return Status.from_dict(result) diff --git a/checkout/contracts/carrier.py b/checkout/contracts/carrier.py index 93302b1..246aaf0 100644 --- a/checkout/contracts/carrier.py +++ b/checkout/contracts/carrier.py @@ -1,8 +1,8 @@ from typing import Protocol -from checkout.entities.instrument import Instrument from checkout.entities.status import Status from checkout.messages.requests.collect import CollectRequest +from checkout.messages.requests.invalidate_token import InvalidateToKenRequest from checkout.messages.requests.redirect import RedirectRequest from checkout.messages.responses.information import InformationResponse from checkout.messages.responses.redirect import RedirectResponse @@ -30,7 +30,7 @@ def reverse(self, transaction_id: str) -> ReverseResponse: Reverse a transaction by its ID. """ - def invalidateToken(self, instrument: Instrument) -> Status: + def invalidateToken(self, invalidate_token_request: InvalidateToKenRequest) -> Status: """ invalidate a token. """ diff --git a/checkout/entities/status.py b/checkout/entities/status.py index 43eedc9..cbbe1d4 100644 --- a/checkout/entities/status.py +++ b/checkout/entities/status.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import Dict, Optional from pydantic import BaseModel, Field @@ -34,6 +34,19 @@ def quick( ) -> "Status": return cls(status=status, reason=reason, message=message, date=date) + @classmethod + def from_dict(cls, data: Dict) -> "Status": + """ + Create a Status instance from a dictionary. + """ + status_data = data.get("status", {}) + return cls( + status=StatusEnum(status_data["status"]), + reason=status_data["reason"], + message=status_data.get("message", ""), + date=status_data.get("date", datetime.now().isoformat()), + ) + def to_dict(self) -> dict: """ Convert the Status object to a dictionary. diff --git a/checkout/tests/feature/test_checkout.py b/checkout/tests/feature/test_checkout.py index 784a48b..2038f4a 100644 --- a/checkout/tests/feature/test_checkout.py +++ b/checkout/tests/feature/test_checkout.py @@ -216,6 +216,23 @@ def test_reverse_valid(self, mock_post, mock_response): self.assertEqual(payment.processor_fields[-1].value, "00") self.assertEqual(payment.processor_fields[-1].keyword, "b24") + @patch("requests.post") + @RedirectResponseMock.mock_response_decorator("invalidate_token_response_successful") + def test_invalidate_token_succssful(self, mock_post, mock_response): + mock_post.return_value = mock_response + + instrument = Instrument( + token=Token( + token="5caef08ecd1230088a12e8f7d9ce20e9134dc6fc049c8a4857c9ba6e942b16b2", subtoken="test_subtoken" + ) + ) + invalidate_token_request = {"locale": "en_US", "instrument": instrument} + result = self.checkout.invalidateToken(invalidate_token_request) + + self.assertEqual(result.status, "APPROVED") + self.assertEqual(result.reason, "00") + self.assertEqual(result.message, "The petition has been successfully approved") + @patch("requests.post") @RedirectResponseMock.mock_response_decorator("redirect_response_fail_authentication", 401) def test_request_fails_bad_request(self, mock_post, mock_response): @@ -283,3 +300,19 @@ def test_reverse_fails_when_transaction_not_found(self, mock_post, mock_response self.assertEqual("FAILED", error_details["status"]["status"]) self.assertEqual("request_not_valid", error_details["status"]["reason"]) self.assertEqual("No existe la transacción que busca", error_details["status"]["message"]) + + @patch("requests.post") + @RedirectResponseMock.mock_response_decorator("invalidate_token_response_fails_token_not_valid", 400) + def test_invalidate_fails_when_token_is_not_valid(self, mock_post, mock_response): + mock_post.return_value = mock_response + + instrument = Instrument(token=Token(token="not_valid_token", subtoken="test_subtoken")) + invalidate_token_request = {"locale": "en_US", "instrument": instrument} + + with self.assertRaises(ClientErrorException) as context: + self.checkout.invalidateToken(invalidate_token_request) + + error_details = json.loads(str(context.exception))["error_details"] + self.assertEqual("FAILED", error_details["status"]["status"]) + self.assertEqual("XN", error_details["status"]["reason"]) + self.assertEqual("The token used is invalid", error_details["status"]["message"]) diff --git a/checkout/tests/mocks/responses/invalidate_token_response_fails_token_not_valid.json b/checkout/tests/mocks/responses/invalidate_token_response_fails_token_not_valid.json new file mode 100644 index 0000000..adb7f31 --- /dev/null +++ b/checkout/tests/mocks/responses/invalidate_token_response_fails_token_not_valid.json @@ -0,0 +1,8 @@ +{ + "status": { + "status": "FAILED", + "reason": "XN", + "message": "The token used is invalid", + "date": "2022-07-27T14:51:27-05:00" + } +} \ No newline at end of file diff --git a/checkout/tests/mocks/responses/invalidate_token_response_successful.json b/checkout/tests/mocks/responses/invalidate_token_response_successful.json new file mode 100644 index 0000000..8cc6eba --- /dev/null +++ b/checkout/tests/mocks/responses/invalidate_token_response_successful.json @@ -0,0 +1,8 @@ +{ + "status": { + "status": "APPROVED", + "reason": "00", + "message": "The petition has been successfully approved", + "date": "2022-07-27T14:51:27-05:00" + } +} \ No newline at end of file From 216c0a2be91040e196f4b9ea57857c46955d20a4 Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 11:50:24 -0500 Subject: [PATCH 08/10] wip --- .github/workflows/python-app.yml | 2 +- pyproject.toml | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index abd4201..d62627b 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -35,7 +35,7 @@ jobs: - name: Run Tests run: | - poetry run pytest --cov=checkout --cov-report=xml + poetry run pytest --cov-report=xml lint: name: Lint and Type Check diff --git a/pyproject.toml b/pyproject.toml index 435895a..357dd79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,6 @@ ignore = ["E402", "W503"] [tool.coverage.run] branch = true source = ["checkout"] -include = ["checkout/*"] omit = [ "checkout/*/__init__.py", "*/__pycache__/*" @@ -94,18 +93,15 @@ omit = [ ] fail_under = 98 show_missing = true -show_covered = true - -[tool.coverage.html] -title = "Test Coverage Report" - -[tool.coverage.xml] -output = "coverage.xml" +[tool.pytest.ini_options] filterwarnings = [ "ignore:SOCKS support in urllib3 requires the installation of optional dependencies:urllib3.exceptions.DependencyWarning" ] +[tool.coverage.xml] +output = "coverage.xml" + [tool.env] PYTHONPATH = "checkout" From 5a4b21686212d4feb089435a6012951784e7bc38 Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 12:01:12 -0500 Subject: [PATCH 09/10] Update README.md --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 35aba5b..5bcefe6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # **Checkout-P2P Python Integration Library** [![pypi](https://img.shields.io/pypi/v/checkout-p2p.svg)](https://pypi.org/project/checkout-p2p/) @@ -59,9 +58,9 @@ checkout = Checkout({ from checkout import RedirectRequest redirect_request = RedirectRequest( - returnUrl="https://example.com/return", - ipAddress="192.168.1.1", - userAgent="Test User Agent", + return_url="https://example.com/return", + ip_address="192.168.1.1", + user_agent="Test User Agent", payment={"reference": "TEST _q", "description": "Test Payment", "amount": {"currency": "COP", "total": 10000}} ) @@ -73,23 +72,66 @@ print("Redirect to:", response.process_url) 3.Query a Payment Request ```python - - query_response = checkout.query(123456) # Replace with your request ID print("Request Status:", query_response.status) ``` -4.Reverse a Payment +4 Charge using token ```python +from checkout import CollectRequest + +collect_request = CollectRequest( + return_url="https://example.com/return", + ip_address="192.168.1.1", + user_agent="Test User Agent", + instrument={"token": {"token" : "your_token_c5583922eccd6d2061c1b0592b099f04e352a894f37ae51cf1a"}}, + payer={ + "email": "andres2@yopmail.com", + "name" : "Andres", + "surname": "López", + "document": "111111111", + "documentType": "CC", + "mobile": "+573111111111" + }, + payment={ + "reference": "TEST_COllECT", + "description": "Test Payment", + "amount": {"currency": "COP", "total": 15000} + } + ) + +# Collect. Returns a `InformationResponse` object. +collect_response = checkout.collect(collect_request) + +print("Collect Status :", collect_response.status) +``` + +5.Reverse a Payment +```python # Reverse a transaction. Returns a `ReverseResponse` object. reverse_response = checkout.reverse("internal_reference") print("Reverse Status:", reverse_response.status) ``` +6.Invalidate token + +```python +invalidate_token_request = { + "locale": "en_US", + "instrument": {"token" : {"token" : "your_token_c5583922eccd6d2061c1b0592b099f04e352a894f37ae51cf1a"}} +} + +# invalite token. Returns a `Status` object. +invalidate_response = checkout.invalidate_token(invalidate_token_request) + +print("Invalidate Status:", invalidate_response.status) +print("Message:", invalidate_response.message) +``` + ## **License** This project is licensed under the MIT License. See the [LICENSE](LICENSE.txt) file for details. From 30ef9f2168931582bb16029009a49f973caf5e42 Mon Sep 17 00:00:00 2001 From: ivan andres Date: Tue, 17 Dec 2024 13:07:58 -0500 Subject: [PATCH 10/10] docs: release v1.0.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fba7f..5b1563e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [V1.0.0] - 2024-12-17 + ### Added - Initial release of Checkout P2P version 1.0.0. diff --git a/pyproject.toml b/pyproject.toml index 357dd79..2a21d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "checkout-p2p" -version = "0.1.7" +version = "1.0.0" description = "Python library for PlaceToPay Checkout integration." authors = ["Iván Andrés López Gómez "] license = "MIT"