Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/)
Expand Down Expand Up @@ -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}}
)

Expand All @@ -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": "[email protected]",
"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.
18 changes: 16 additions & 2 deletions checkout/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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)
10 changes: 10 additions & 0 deletions checkout/clients/rest_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
7 changes: 7 additions & 0 deletions checkout/contracts/carrier.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
"""
15 changes: 14 additions & 1 deletion checkout/entities/status.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Optional
from typing import Dict, Optional

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 1 addition & 11 deletions checkout/entities/subscription_information.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 21 additions & 8 deletions checkout/entities/token.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
}
15 changes: 15 additions & 0 deletions checkout/messages/requests/invalidate_token.py
Original file line number Diff line number Diff line change
@@ -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()}
33 changes: 33 additions & 0 deletions checkout/tests/feature/test_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"status": {
"status": "FAILED",
"reason": "XN",
"message": "The token used is invalid",
"date": "2022-07-27T14:51:27-05:00"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"status": {
"status": "APPROVED",
"reason": "00",
"message": "The petition has been successfully approved",
"date": "2022-07-27T14:51:27-05:00"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
Loading
Loading