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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
20 changes: 16 additions & 4 deletions checkout/entities/person.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
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")
email: Optional[str] = Field(default="", description="Email address of the person")
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:
Expand All @@ -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 "",
}
24 changes: 16 additions & 8 deletions checkout/entities/recurring.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field


class Recurring(BaseModel):
Expand All @@ -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,
}
12 changes: 7 additions & 5 deletions checkout/entities/subscription.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,14 +9,16 @@
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:
"""
Convert the subscription object to a dictionary including fields.
"""
data = self.model_dump()
data["fields"] = self.fields_to_array()
del data["custom_fields"]

return data
6 changes: 2 additions & 4 deletions checkout/messages/requests/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,17 @@
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


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:
"""
Expand Down
28 changes: 25 additions & 3 deletions checkout/messages/requests/redirect.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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(),
}
Loading
Loading