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/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/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. 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 6b9230d..0aef6ba 100644 --- a/checkout/clients/rest_client.py +++ b/checkout/clients/rest_client.py @@ -2,7 +2,9 @@ from checkout.contracts.carrier import Carrier 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 @@ -56,3 +58,11 @@ def reverse(self, transaction_id: str) -> ReverseResponse: """ result = self._post("api/reverse", {"internalReference": transaction_id}) return ReverseResponse(**result) + + def invalidateToken(self, invalidate_token_request: InvalidateToKenRequest) -> Status: + """ + Invalidate a token. + """ + 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 4dfd1fa..246aaf0 100644 --- a/checkout/contracts/carrier.py +++ b/checkout/contracts/carrier.py @@ -1,6 +1,8 @@ from typing import Protocol +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 @@ -27,3 +29,8 @@ def reverse(self, transaction_id: str) -> ReverseResponse: """ Reverse a transaction by its ID. """ + + 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/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..f5debc5 100644 --- a/checkout/entities/token.py +++ b/checkout/entities/token.py @@ -1,25 +1,28 @@ from datetime import datetime +from typing import Optional -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") + installments: Optional[int] = Field(default=None, 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 +31,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/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/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 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, ) 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/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" diff --git a/test_invalidate_token_test.py b/test_invalidate_token_test.py new file mode 100644 index 0000000..e69de29