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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- added: adds authentication module
## [V1.0.0] - 2024-12-17

### Added

- Initial release of Checkout P2P version 1.0.0.
- 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": "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.
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.
"""
28 changes: 28 additions & 0 deletions checkout/decorators/filter_empty_values.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion checkout/entities/amount.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions checkout/entities/dispersion_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ 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"),
"payment": data.get("payment"),
"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":
Expand Down
12 changes: 7 additions & 5 deletions checkout/entities/name_value_pair.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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


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}
38 changes: 24 additions & 14 deletions checkout/entities/payment.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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
Loading
Loading