diff --git a/README.md b/README.md index 61512e1..18389dc 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,77 @@ First time using Mercado Pago? Create your [Mercado Pago account](https://www.me Copy your `Access Token` in the [credentials panel](https://www.mercadopago.com/developers/panel/credentials) and replace the text `YOUR_ACCESS_TOKEN` with it. -### Simple usage - +### Simple usage — Orders API + +The [Orders API](https://www.mercadopago.com/developers/en/reference/online-payments/checkout-api/create-order/post) (`/v1/orders`) is the recommended way to accept payments. `sdk.order().create()` accepts either a plain `dict` or the optional typed request dataclasses (`OrderCreateRequest` and friends). Both routes produce the exact same JSON body. + +```python +import mercadopago + +sdk = mercadopago.SDK("YOUR_ACCESS_TOKEN") + +request_options = mercadopago.config.RequestOptions() +request_options.custom_headers = { + 'x-idempotency-key': '' +} + +order_data = { + "type": "online", + "total_amount": "100.00", + "external_reference": "ext_ref_1234", + "transactions": { + "payments": [ + { + "amount": "100.00", + "payment_method": { + "id": "master", + "type": "credit_card", + "token": "CARD_TOKEN", + "installments": 1, + }, + } + ] + }, + "payer": { + "email": "test_user_123456@testuser.com" + }, +} +result = sdk.order().create(order_data, request_options) +order = result["response"] + +print(order) +``` + +#### Typed request classes (optional) + +Instead of a `dict`, you can build the request with the typed dataclasses. `None` +fields are omitted from the JSON body automatically, matching the `dict` route. + +```python +import mercadopago +from mercadopago.resources.order_create import OrderCreateRequest, OrderPayerRequest +from mercadopago.resources.order_item import OrderItemRequest + +sdk = mercadopago.SDK("YOUR_ACCESS_TOKEN") + +order = OrderCreateRequest( + type="online", + total_amount="100.00", + external_reference="ext_ref_1234", + payer=OrderPayerRequest(email="test_user_123456@testuser.com"), + items=[OrderItemRequest(title="A book", unit_price="100.00", quantity=1)], +) + +result = sdk.order().create(order) +print(result["response"]) +``` + +For a complete recurring / Automatic Payments example (stored credential, +subscription data, integration data), see +[`examples/order/create_order_automatic_payment.py`](examples/order/create_order_automatic_payment.py). + +### Creating a payment (legacy Payments API) + ```python import mercadopago diff --git a/examples/order/create_order_automatic_payment.py b/examples/order/create_order_automatic_payment.py index 172ccad..9a0c70e 100644 --- a/examples/order/create_order_automatic_payment.py +++ b/examples/order/create_order_automatic_payment.py @@ -116,7 +116,7 @@ reason="recurring", store_payment_method=False, first_payment=False, - prev_transaction_ref=first_transaction_id, # required + previous_transaction_reference=first_transaction_id, # required ) ), "subscription_data": dataclasses.asdict( diff --git a/mercadopago/resources/__init__.py b/mercadopago/resources/__init__.py index c75d60d..068443e 100644 --- a/mercadopago/resources/__init__.py +++ b/mercadopago/resources/__init__.py @@ -17,6 +17,7 @@ from mercadopago.resources.merchant_order import MerchantOrder from mercadopago.resources.oauth import OAuth from mercadopago.resources.order import Order +from mercadopago.resources.order_automatic_payments import OrderAutomaticPayments from mercadopago.resources.order_checkout_pro import ( OrderCheckoutProConfig, OrderCheckoutProInstallments, @@ -26,6 +27,38 @@ OrderCheckoutProTrack, OrderCheckoutProDict, ) +from mercadopago.resources.order_create import ( + OrderCreateRequest, + order_request_to_dict, +) +from mercadopago.resources.order_integration_data import ( + OrderIntegrationData, + OrderSponsor, +) +from mercadopago.resources.item import ItemRequest +from mercadopago.resources.payer import ( + PayerAddress, + PayerIdentification, + PayerPhone, + PayerRequest, +) +from mercadopago.resources.shipment import ( + ShipmentAddress, + ShipmentFreeMethod, + ShipmentRequest, +) +from mercadopago.resources.order_stored_credential import OrderStoredCredential +from mercadopago.resources.order_subscription_data import ( + OrderInvoicePeriod, + OrderSubscriptionData, + OrderSubscriptionSequence, +) +from mercadopago.resources.order_transaction import ( + OrderPaymentMethodRequest, + OrderPaymentRequest, + OrderTransactionRequest, +) +from mercadopago.resources.order_transaction_security import OrderTransactionSecurity from mercadopago.resources.payment import Payment from mercadopago.resources.payment_methods import PaymentMethods from mercadopago.resources.plan import Plan @@ -50,6 +83,7 @@ 'MerchantOrder', 'OAuth', 'Order', + 'OrderAutomaticPayments', 'OrderCheckoutProConfig', 'OrderCheckoutProInstallments', 'OrderCheckoutProInterestFree', @@ -57,6 +91,25 @@ 'OrderCheckoutProPaymentMethod', 'OrderCheckoutProTrack', 'OrderCheckoutProDict', + 'OrderCreateRequest', + 'OrderIntegrationData', + 'OrderInvoicePeriod', + 'ItemRequest', + 'PayerAddress', + 'PayerIdentification', + 'PayerPhone', + 'PayerRequest', + 'ShipmentAddress', + 'ShipmentFreeMethod', + 'ShipmentRequest', + 'OrderSponsor', + 'OrderStoredCredential', + 'OrderSubscriptionData', + 'OrderSubscriptionSequence', + 'OrderPaymentMethodRequest', + 'OrderPaymentRequest', + 'OrderTransactionRequest', + 'OrderTransactionSecurity', 'Payment', 'PaymentMethods', 'Plan', @@ -67,4 +120,5 @@ 'RequestOptions', 'Subscription', 'User', + 'order_request_to_dict', ) diff --git a/mercadopago/resources/item.py b/mercadopago/resources/item.py new file mode 100644 index 0000000..6fb8c96 --- /dev/null +++ b/mercadopago/resources/item.py @@ -0,0 +1,39 @@ +"""Dataclass for line items in API requests.""" +from dataclasses import dataclass +from typing import Optional + + +# pylint: disable=too-many-instance-attributes # DTO: fields mirror the API items contract +@dataclass +class ItemRequest: + """A single line item within an API request. + + Use this dataclass to build an entry of the ``items`` array when creating + an order. Convert to dict with ``dataclasses.asdict()`` (``None`` fields are + filtered out before sending, per the omit-empty behavior of the API). + + Attributes: + title: Display name of the item. Type: str. + type: Item type/category classifier. Type: str. + warranty: Whether the item includes a warranty. Type: bool. + event_date: ISO 8601 date associated with the item (e.g. event tickets). + Type: str. + unit_price: Price per unit as a decimal string (e.g. ``"100.00"``). + Type: str. + external_code: Merchant-side external identifier for the item. Type: str. + category_id: MercadoPago category identifier. Type: str. + description: Free-text description of the item. Type: str. + picture_url: URL of an image representing the item. Type: str. + quantity: Number of units. Type: int. + """ + + title: Optional[str] = None + type: Optional[str] = None + warranty: Optional[bool] = None + event_date: Optional[str] = None + unit_price: Optional[str] = None + external_code: Optional[str] = None + category_id: Optional[str] = None + description: Optional[str] = None + picture_url: Optional[str] = None + quantity: Optional[int] = None diff --git a/mercadopago/resources/order.py b/mercadopago/resources/order.py index 96ab70e..cc6e33d 100644 --- a/mercadopago/resources/order.py +++ b/mercadopago/resources/order.py @@ -7,8 +7,11 @@ `API reference `_ """ +from dataclasses import is_dataclass + from mercadopago.core import MPBase from mercadopago.pagination.iterator import search_auto_paging_iter as _paging_iter +from mercadopago.resources.order_create import order_request_to_dict class Order(MPBase): """Manages orders and their associated transactions. @@ -89,21 +92,30 @@ def search(self, filters=None, request_options=None): def create(self, order_object, request_options=None): """Creates a new order. + Accepts either a plain ``dict`` (the historical, dynamic route) or an + :class:`~mercadopago.resources.order_create.OrderCreateRequest` typed + dataclass. When a dataclass is passed it is converted to a ``dict`` with + ``None`` fields omitted, producing the same JSON body as the dict route + (DD-3). The dict route is unchanged and fully backward compatible. + Args: order_object: Dict describing the order (items, transactions, - payer, etc.). + payer, etc.), or an ``OrderCreateRequest`` dataclass instance. request_options: Per-call configuration overrides. Raises: - ValueError: If *order_object* is not a ``dict``. + ValueError: If *order_object* is neither a ``dict`` nor a dataclass + instance. Returns: dict: Created order including its ``id``. Reference: https://www.mercadopago.com/developers/en/reference/online-payments/checkout-api/create-order/post """ - if not isinstance(order_object, dict): - raise ValueError("Param order_object must be a Dictionary") + if is_dataclass(order_object) and not isinstance(order_object, type): + order_object = order_request_to_dict(order_object) + elif not isinstance(order_object, dict): + raise ValueError("Param order_object must be a Dictionary or an OrderCreateRequest") return self._post(uri="/v1/orders", data=order_object, request_options=request_options) diff --git a/mercadopago/resources/order_create.py b/mercadopago/resources/order_create.py new file mode 100644 index 0000000..c0122d8 --- /dev/null +++ b/mercadopago/resources/order_create.py @@ -0,0 +1,112 @@ +"""Root request dataclasses for the MercadoPago Orders API. + +These dataclasses model the ``POST /v1/orders`` request body. They are an +optional, typed alternative to passing a plain ``dict`` to +:meth:`~mercadopago.resources.order.Order.create`. Build the request with the +dataclasses and convert it to a ``dict`` with ``dataclasses.asdict()``; ``None`` +fields are filtered out before serialization so the resulting JSON matches the +dict path exactly. + +The dict path continues to work unchanged; these dataclasses are purely additive. +""" +from dataclasses import ( + asdict, + dataclass, + field, + is_dataclass, +) +from typing import ( + List, + Optional, + Union, +) + +from mercadopago.resources.item import ItemRequest +from mercadopago.resources.order_integration_data import OrderIntegrationData +from mercadopago.resources.shipment import ShipmentRequest +from mercadopago.resources.order_transaction import OrderTransactionRequest +from mercadopago.resources.payer import PayerRequest + + +def _filter_none(value): + """Recursively drop ``None`` values from dicts/lists (DD-3, omit-empty).""" + if isinstance(value, dict): + return {k: _filter_none(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [_filter_none(v) for v in value] + return value + + +def order_request_to_dict(request): + """Convert a request dataclass into a ``dict`` with ``None`` fields omitted. + + This is the canonical way to turn any of the Orders API request dataclasses + (``OrderCreateRequest`` and its nested objects) into the ``dict`` accepted by + :meth:`~mercadopago.resources.order.Order.create`. It runs + ``dataclasses.asdict()`` and then recursively strips keys whose value is + ``None`` so the resulting JSON matches the plain-dict path exactly (DD-3). + + Args: + request: A request dataclass instance (or any dataclass instance). + + Returns: + dict: The request as a plain ``dict`` with ``None`` fields removed. + + Raises: + TypeError: If *request* is not a dataclass instance. + """ + if not is_dataclass(request) or isinstance(request, type): + raise TypeError("request must be a dataclass instance") + return _filter_none(asdict(request)) + + +# pylint: disable=too-many-instance-attributes # DTO: fields mirror the Orders API root request contract +@dataclass +class OrderCreateRequest: + """Root request body for creating an order. + + Optional typed alternative to a plain ``dict``. Convert with + ``dataclasses.asdict()``; ``None`` fields are filtered out before sending. + + Attributes: + type: Order type (e.g. ``"online"``). Type: str. + external_reference: Merchant-side reference for the order. Type: str. + total_amount: Total order amount as a decimal string. Type: str. + currency: Currency identifier (e.g. ``"BRL"``). Type: str. + capture_mode: Capture mode (e.g. ``"automatic_async"``). Type: str. + processing_mode: Processing mode (e.g. ``"automatic"``). Type: str. + description: Free-text order description. Type: str. + marketplace: Marketplace identifier. Type: str. + marketplace_fee: Marketplace fee as a decimal string. Type: str. + expiration_time: Order expiration time (ISO 8601 / duration). Type: str. + checkout_available_at: When the checkout becomes available. Type: str. + transactions: Typed transactions payload. Accepts an + :class:`~mercadopago.resources.order_transaction.OrderTransactionRequest` + for a fully typed AP chain, or a plain ``dict`` for backward + compatibility. + payer: Payer information. + items: Line items in the order. + config: Order configuration payload. + shipment: Shipment configuration. + integration_data: Integration metadata. + additional_info: Free-form additional information (kept as-is). + """ + + type: Optional[str] = None + external_reference: Optional[str] = None + total_amount: Optional[str] = None + currency: Optional[str] = None + capture_mode: Optional[str] = None + processing_mode: Optional[str] = None + description: Optional[str] = None + marketplace: Optional[str] = None + marketplace_fee: Optional[str] = None + expiration_time: Optional[str] = None + checkout_available_at: Optional[str] = None + transactions: Optional[Union[OrderTransactionRequest, dict]] = None + payer: Optional[PayerRequest] = None + items: Optional[List[ItemRequest]] = field(default=None) + config: Optional[dict] = None + shipment: Optional[ShipmentRequest] = None + integration_data: Optional[OrderIntegrationData] = None + additional_info: Optional[dict] = None diff --git a/mercadopago/resources/order_stored_credential.py b/mercadopago/resources/order_stored_credential.py index e3d296e..0d4a77f 100644 --- a/mercadopago/resources/order_stored_credential.py +++ b/mercadopago/resources/order_stored_credential.py @@ -19,13 +19,13 @@ class OrderStoredCredential: Type: bool. first_payment: ``True`` for the initial authorization; ``False`` for subsequent recurring charges. Type: bool. - prev_transaction_ref: Identifier of the previous transaction in the recurring - series. Required from the second charge onwards to link this payment to the - original card-network authorization. Type: str. + previous_transaction_reference: Identifier of the previous transaction in the + recurring series. Required from the second charge onwards to link this payment + to the original card-network authorization. Type: str. """ payment_initiator: Optional[str] = None reason: Optional[str] = None store_payment_method: Optional[bool] = None first_payment: Optional[bool] = None - prev_transaction_ref: Optional[str] = None + previous_transaction_reference: Optional[str] = None diff --git a/mercadopago/resources/order_transaction.py b/mercadopago/resources/order_transaction.py new file mode 100644 index 0000000..dc14427 --- /dev/null +++ b/mercadopago/resources/order_transaction.py @@ -0,0 +1,78 @@ +"""Dataclasses for the transactions payload in Orders API requests. + +These dataclasses model the ``transactions.payments[]`` structure of the +``POST /v1/orders`` request body. They complete the typed chain started by +:class:`~mercadopago.resources.order_create.OrderCreateRequest`, allowing +Automatic Payments fields to be built without raw dicts. + +The plain dict path continues to work unchanged; these dataclasses are +purely additive. +""" +from dataclasses import dataclass +from typing import List, Optional + +from mercadopago.resources.order_automatic_payments import OrderAutomaticPayments +from mercadopago.resources.order_stored_credential import OrderStoredCredential +from mercadopago.resources.order_subscription_data import OrderSubscriptionData + + +@dataclass +class OrderPaymentMethodRequest: + """Payment method details for a transaction within an order. + + Attributes: + id: Payment method identifier (e.g. ``"master"``). Type: str. + type: Payment method type (e.g. ``"credit_card"``). Type: str. + token: Tokenized card identifier. Type: str. + installments: Number of installments. Type: int. + statement_descriptor: Descriptor shown on the cardholder statement. + Type: str. + financial_institution: Financial institution code (e.g. PSE). Type: str. + """ + + id: Optional[str] = None + type: Optional[str] = None + token: Optional[str] = None + installments: Optional[int] = None + statement_descriptor: Optional[str] = None + financial_institution: Optional[str] = None + + +@dataclass +class OrderPaymentRequest: + """A single payment transaction within an order. + + Use this dataclass to build an entry of the ``transactions.payments`` + array. It provides a fully typed path to all Automatic Payments fields + (``automatic_payments``, ``stored_credential``, ``subscription_data``). + + Attributes: + amount: Payment amount as a decimal string. Type: str. + expiration_time: ISO 8601 duration or date-time for expiration. + Type: str. + date_of_expiration: ISO 8601 date-time after which the payment + can no longer be collected. Type: str. + payment_method: Payment method details. + automatic_payments: Automatic (recurring) payment configuration. + stored_credential: Card-on-file metadata for recurring charges. + subscription_data: Subscription billing data for this payment. + """ + + amount: Optional[str] = None + expiration_time: Optional[str] = None + date_of_expiration: Optional[str] = None + payment_method: Optional[OrderPaymentMethodRequest] = None + automatic_payments: Optional[OrderAutomaticPayments] = None + stored_credential: Optional[OrderStoredCredential] = None + subscription_data: Optional[OrderSubscriptionData] = None + + +@dataclass +class OrderTransactionRequest: + """Transactions payload for an order creation request. + + Attributes: + payments: List of payment transactions for the order. + """ + + payments: Optional[List[OrderPaymentRequest]] = None diff --git a/mercadopago/resources/order_transaction_security.py b/mercadopago/resources/order_transaction_security.py new file mode 100644 index 0000000..906f09b --- /dev/null +++ b/mercadopago/resources/order_transaction_security.py @@ -0,0 +1,21 @@ +"""Dataclass for transaction security data in order requests.""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class OrderTransactionSecurity: + """Transaction security settings for an order request. + + Nested under ``config.online.transaction_security`` (not at the request root). + Convert to dict with ``dataclasses.asdict()`` (``None`` fields are filtered + out before sending, per the omit-empty behavior of the API). + + Attributes: + validation: Validation strategy applied to the transaction (e.g. + ``"complete"``). Type: str. + liability_shift: Liability shift indicator for 3-D Secure flows. Type: str. + """ + + validation: Optional[str] = None + liability_shift: Optional[str] = None diff --git a/mercadopago/resources/payer.py b/mercadopago/resources/payer.py new file mode 100644 index 0000000..91292b5 --- /dev/null +++ b/mercadopago/resources/payer.py @@ -0,0 +1,82 @@ +"""Dataclasses for payer data in API requests.""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class PayerPhone: + """Payer phone number. + + Attributes: + area_code: Phone area code. Type: str. + number: Phone number without the area code. Type: str. + """ + + area_code: Optional[str] = None + number: Optional[str] = None + + +# pylint: disable=too-many-instance-attributes # DTO: fields mirror the API payer.address contract +@dataclass +class PayerAddress: + """Payer address. + + Attributes: + zip_code: Postal / ZIP code. Type: str. + street_name: Name of the street. Type: str. + street_number: Street number. Type: str. + neighborhood: Neighborhood name. Type: str. + city: City name. Type: str. + state: State or province. Type: str. + complement: Additional address details. Type: str. + country: Country name or code. Type: str. + """ + + zip_code: Optional[str] = None + street_name: Optional[str] = None + street_number: Optional[str] = None + neighborhood: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + complement: Optional[str] = None + country: Optional[str] = None + + +@dataclass +class PayerIdentification: + """Payer identification document. + + Attributes: + type: Identification document type (e.g. ``"CPF"``). Type: str. + number: Identification document number. Type: str. + """ + + type: Optional[str] = None + number: Optional[str] = None + + +# pylint: disable=too-many-instance-attributes # DTO: fields mirror the API payer contract +@dataclass +class PayerRequest: + """Payer information for an API request. + + Attributes: + email: Payer email address. Type: str. + first_name: Payer first name. Type: str. + last_name: Payer last name. Type: str. + customer_id: Stored customer identifier. Type: str. + entity_type: Payer entity type (``"individual"`` | ``"association"``). + Type: str. + identification: Payer identification document. + phone: Payer phone number. + address: Payer address. + """ + + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + customer_id: Optional[str] = None + entity_type: Optional[str] = None + identification: Optional[PayerIdentification] = None + phone: Optional[PayerPhone] = None + address: Optional[PayerAddress] = None diff --git a/mercadopago/resources/shipment.py b/mercadopago/resources/shipment.py new file mode 100644 index 0000000..723eef9 --- /dev/null +++ b/mercadopago/resources/shipment.py @@ -0,0 +1,73 @@ +"""Dataclasses for shipment data in API requests.""" +from dataclasses import ( + dataclass, + field, +) +from typing import ( + List, + Optional, +) + + +# pylint: disable=too-many-instance-attributes # DTO: fields mirror the API shipment.address contract +@dataclass +class ShipmentAddress: + """Delivery address for a shipment. + + Attributes: + street_name: Name of the street. Type: str. + street_number: Street number. Type: str. + zip_code: Postal / ZIP code. Type: str. + floor: Floor within the building. Type: str. + apartment: Apartment / unit identifier. Type: str. + neighborhood: Neighborhood name. Type: str. + state: State or province. Type: str. + city: City name. Type: str. + complement: Additional address details. Type: str. + """ + + street_name: Optional[str] = None + street_number: Optional[str] = None + zip_code: Optional[str] = None + floor: Optional[str] = None + apartment: Optional[str] = None + neighborhood: Optional[str] = None + state: Optional[str] = None + city: Optional[str] = None + complement: Optional[str] = None + + +@dataclass +class ShipmentFreeMethod: + """A free-shipping method. + + Attributes: + id: Identifier of the free shipping method. Type: int. + """ + + id: Optional[int] = None + + +@dataclass +class ShipmentRequest: + """Shipment configuration for an API request. + + Use this dataclass to build the ``shipment`` payload when creating an order. + Convert to dict with ``dataclasses.asdict()`` (``None`` fields are filtered + out before sending, per the omit-empty behavior of the API). + + Attributes: + mode: Shipping mode (e.g. ``"me2"``, ``"custom"``). Type: str. + local_pickup: Whether the buyer picks up the item locally. Type: bool. + cost: Shipping cost as a decimal string. Type: str. + free_shipping: Whether shipping is free. Type: bool. + free_methods: Free shipping methods available. + address: Delivery address for the shipment. + """ + + mode: Optional[str] = None + local_pickup: Optional[bool] = None + cost: Optional[str] = None + free_shipping: Optional[bool] = None + free_methods: Optional[List[ShipmentFreeMethod]] = field(default=None) + address: Optional[ShipmentAddress] = None diff --git a/tests/test_order_request_dataclasses.py b/tests/test_order_request_dataclasses.py new file mode 100644 index 0000000..8806e0c --- /dev/null +++ b/tests/test_order_request_dataclasses.py @@ -0,0 +1,507 @@ +"""Offline unit + integration tests for the typed Order request dataclasses. + +These tests do not hit the live API. They verify: + * new dataclasses produce the correct snake_case keys, + * None filtering (DD-3) removes unset fields, + * the existing dict path still works (backward compatibility), + * the typed Automatic Payments flow serializes correctly. +""" +import dataclasses +import json +import unittest + +import mercadopago +from mercadopago.http import HttpClient +from mercadopago.resources.order_automatic_payments import OrderAutomaticPayments +from mercadopago.resources.order_create import ( + OrderCreateRequest, + order_request_to_dict, +) +from mercadopago.resources.order_integration_data import ( + OrderIntegrationData, + OrderSponsor, +) +from mercadopago.resources.item import ItemRequest +from mercadopago.resources.payer import ( + PayerAddress, + PayerIdentification, + PayerPhone, + PayerRequest, +) +from mercadopago.resources.shipment import ( + ShipmentAddress, + ShipmentFreeMethod, + ShipmentRequest, +) +from mercadopago.resources.order_stored_credential import OrderStoredCredential +from mercadopago.resources.order_subscription_data import ( + OrderInvoicePeriod, + OrderSubscriptionData, + OrderSubscriptionSequence, +) +from mercadopago.resources.order_transaction import ( + OrderPaymentMethodRequest, + OrderPaymentRequest, + OrderTransactionRequest, +) +from mercadopago.resources.order_transaction_security import OrderTransactionSecurity + + +class _CapturingHttpClient(HttpClient): + """HttpClient stub that captures the request body instead of sending it.""" + + def __init__(self): + self.last_url = None + self.last_data = None + + def post(self, url, headers, # noqa: D401 # pylint: disable=too-many-arguments,too-many-positional-arguments + data=None, params=None, timeout=None, maxretries=None, + retry_on=None, backoff_factor=None): + self.last_url = url + self.last_data = data + return {"status": 201, "response": {"id": "ORDER_ID", "status": "processed"}} + + +def _make_sdk(): + http = _CapturingHttpClient() + sdk = mercadopago.SDK("TEST_TOKEN", http_client=http) + return sdk, http + + +class TestItemRequest(unittest.TestCase): + def test_snake_case_keys(self): + item = ItemRequest( + title="A book", + type="physical", + warranty=True, + event_date="2026-07-01", + unit_price="100.00", + external_code="EXT-1", + category_id="books", + description="A nice book", + picture_url="https://example.com/p.png", + quantity=2, + ) + as_dict = order_request_to_dict(item) + self.assertEqual( + set(as_dict.keys()), + { + "title", "type", "warranty", "event_date", "unit_price", + "external_code", "category_id", "description", "picture_url", + "quantity", + }, + ) + self.assertEqual(as_dict["unit_price"], "100.00") + self.assertEqual(as_dict["quantity"], 2) + + def test_none_fields_filtered(self): + item = ItemRequest(title="Only title", quantity=1) + as_dict = order_request_to_dict(item) + self.assertEqual(as_dict, {"title": "Only title", "quantity": 1}) + + +class TestShipmentRequest(unittest.TestCase): + def test_full_shipment_snake_case(self): + shipment = ShipmentRequest( + mode="me2", + local_pickup=False, + cost="10.00", + free_shipping=True, + free_methods=[ShipmentFreeMethod(id=1), ShipmentFreeMethod(id=2)], + address=ShipmentAddress( + street_name="Main", + street_number="123", + zip_code="0000", + floor="2", + apartment="B", + neighborhood="Centro", + state="SP", + city="Sao Paulo", + complement="near park", + ), + ) + as_dict = order_request_to_dict(shipment) + self.assertEqual(as_dict["free_methods"], [{"id": 1}, {"id": 2}]) + self.assertEqual(as_dict["address"]["street_name"], "Main") + self.assertIn("free_shipping", as_dict) + self.assertIn("local_pickup", as_dict) + + def test_partial_shipment_filters_none(self): + shipment = ShipmentRequest(mode="custom", cost="5.00") + as_dict = order_request_to_dict(shipment) + self.assertEqual(as_dict, {"mode": "custom", "cost": "5.00"}) + + +class TestOrderPayer(unittest.TestCase): + def test_payer_phone_and_address(self): + payer = PayerRequest( + email="buyer@example.com", + first_name="Jane", + last_name="Doe", + customer_id="CUST-1", + entity_type="individual", + identification=PayerIdentification(type="CPF", number="12345678909"), + phone=PayerPhone(area_code="11", number="999999999"), + address=PayerAddress( + zip_code="0000", + street_name="Main", + street_number="123", + neighborhood="Centro", + city="Sao Paulo", + state="SP", + complement="apt 1", + country="BR", + ), + ) + as_dict = order_request_to_dict(payer) + self.assertEqual(as_dict["phone"], {"area_code": "11", "number": "999999999"}) + self.assertEqual(as_dict["address"]["zip_code"], "0000") + self.assertEqual(as_dict["identification"], {"type": "CPF", "number": "12345678909"}) + + def test_payer_email_only_filters_none(self): + payer = PayerRequest(email="buyer@example.com") + self.assertEqual(order_request_to_dict(payer), {"email": "buyer@example.com"}) + + +class TestOrderTransactionSecurity(unittest.TestCase): + def test_snake_case_keys(self): + sec = OrderTransactionSecurity(validation="complete", liability_shift="yes") + self.assertEqual( + order_request_to_dict(sec), + {"validation": "complete", "liability_shift": "yes"}, + ) + + +class TestOrderCreateRequestRootFields(unittest.TestCase): + def test_all_root_fields_present(self): + req = OrderCreateRequest( + type="online", + external_reference="ext_ref_1234", + total_amount="200.00", + currency="BRL", + capture_mode="automatic_async", + processing_mode="automatic", + description="An order", + marketplace="NONE", + marketplace_fee="1.00", + expiration_time="P3D", + checkout_available_at="2026-07-22T00:00:00.000-03:00", + ) + as_dict = order_request_to_dict(req) + for key in ( + "description", "marketplace", "marketplace_fee", + "expiration_time", "checkout_available_at", "currency", + ): + self.assertIn(key, as_dict) + self.assertEqual(as_dict["marketplace_fee"], "1.00") + + def test_none_root_fields_filtered(self): + req = OrderCreateRequest(type="online", total_amount="10.00") + as_dict = order_request_to_dict(req) + self.assertEqual(as_dict, {"type": "online", "total_amount": "10.00"}) + self.assertNotIn("marketplace", as_dict) + + def test_config_online_transaction_security_nesting(self): + # transaction_security lives under config.online (not root). + req = OrderCreateRequest( + type="online", + config={ + "online": { + "transaction_security": order_request_to_dict( + OrderTransactionSecurity(validation="complete") + ) + } + }, + ) + as_dict = order_request_to_dict(req) + self.assertEqual( + as_dict["config"]["online"]["transaction_security"], + {"validation": "complete"}, + ) + self.assertNotIn("transaction_security", as_dict) + + def test_helper_rejects_non_dataclass(self): + with self.assertRaises(TypeError): + order_request_to_dict({"type": "online"}) + + +class TestOrderCreateDualPath(unittest.TestCase): + def test_dict_path_backward_compat(self): + sdk, http = _make_sdk() + order_object = { + "type": "online", + "total_amount": "200.00", + "external_reference": "ext_ref_1234", + "payer": {"email": "buyer@example.com"}, + } + result = sdk.order().create(order_object) + self.assertEqual(result["status"], 201) + sent = json.loads(http.last_data) + self.assertEqual(sent, order_object) + + def test_dataclass_path_matches_dict_path(self): + sdk, http = _make_sdk() + typed = OrderCreateRequest( + type="online", + total_amount="200.00", + external_reference="ext_ref_1234", + payer=PayerRequest(email="buyer@example.com"), + items=[ItemRequest(title="A book", unit_price="200.00", quantity=1)], + ) + sdk.order().create(typed) + sent_typed = json.loads(http.last_data) + + equivalent_dict = { + "type": "online", + "total_amount": "200.00", + "external_reference": "ext_ref_1234", + "payer": {"email": "buyer@example.com"}, + "items": [{"title": "A book", "unit_price": "200.00", "quantity": 1}], + } + sdk2, http2 = _make_sdk() + sdk2.order().create(equivalent_dict) + sent_dict = json.loads(http2.last_data) + + self.assertEqual(sent_typed, sent_dict) + + def test_invalid_type_raises(self): + sdk, _ = _make_sdk() + with self.assertRaises(ValueError): + sdk.order().create("not-a-dict") + + +class TestAutomaticPaymentsTypedFlow(unittest.TestCase): + def test_ap_flow_snake_case(self): + sdk, http = _make_sdk() + order_object = { + "type": "online", + "total_amount": "100.00", + "external_reference": "subscription-001-payment-2", + "payer": {"email": "customer@example.com", "customer_id": "CUSTOMER_ID"}, + "transactions": { + "payments": [ + { + "amount": "100.00", + "payment_method": { + "id": "master", + "type": "credit_card", + "token": "CARD_TOKEN", + "installments": 1, + }, + "automatic_payments": dataclasses.asdict( + OrderAutomaticPayments( + payment_profile_id="PROFILE", + schedule_date="2026-08-01T00:00:00.000-04:00", + due_date="2026-08-05T00:00:00.000-04:00", + retries=3, + ) + ), + "stored_credential": dataclasses.asdict( + OrderStoredCredential( + payment_initiator="merchant", + reason="recurring", + store_payment_method=False, + first_payment=False, + previous_transaction_reference="PREV_TX", + ) + ), + "subscription_data": dataclasses.asdict( + OrderSubscriptionData( + invoice_id="INVOICE_002", + billing_date="2026-07-01", + subscription_sequence=OrderSubscriptionSequence(number=2, total=12), + invoice_period=OrderInvoicePeriod(type="monthly", period=1), + ) + ), + } + ] + }, + "integration_data": dataclasses.asdict( + OrderIntegrationData( + integrator_id="INT-1", + platform_id="PLAT-1", + corporation_id="CORP-1", + sponsor=OrderSponsor(id="SPONSOR-1"), + ) + ), + } + result = sdk.order().create(order_object) + self.assertEqual(result["status"], 201) + sent = json.loads(http.last_data) + payment = sent["transactions"]["payments"][0] + self.assertEqual(payment["stored_credential"]["previous_transaction_reference"], "PREV_TX") + self.assertEqual(payment["automatic_payments"]["payment_profile_id"], "PROFILE") + self.assertEqual( + payment["subscription_data"]["subscription_sequence"], + {"number": 2, "total": 12}, + ) + self.assertEqual(sent["integration_data"]["sponsor"], {"id": "SPONSOR-1"}) + + def test_ap_typed_via_dataclass_root(self): + # AP nested dicts inside a typed OrderCreateRequest (transactions kept as dict). + sdk, http = _make_sdk() + typed = OrderCreateRequest( + type="online", + total_amount="100.00", + payer=PayerRequest(email="customer@example.com", customer_id="CUSTOMER_ID"), + transactions={ + "payments": [ + { + "amount": "100.00", + "stored_credential": dataclasses.asdict( + OrderStoredCredential( + payment_initiator="merchant", + first_payment=True, + ) + ), + } + ] + }, + ) + sdk.order().create(typed) + sent = json.loads(http.last_data) + sc = sent["transactions"]["payments"][0]["stored_credential"] + # first_payment stays; None fields (reason, prev_transaction_ref) are dropped. + self.assertEqual(sc, {"payment_initiator": "merchant", "first_payment": True}) + + +class TestFullyTypedAPChain(unittest.TestCase): + """Verify the typed chain OrderCreateRequest → OrderTransactionRequest → + OrderPaymentRequest → AP dataclasses produces the correct JSON body.""" + + def test_fully_typed_ap_chain_serializes_correctly(self): + sdk, http = _make_sdk() + typed = OrderCreateRequest( + type="online", + total_amount="100.00", + external_reference="ap-typed-chain-001", + payer=PayerRequest( + email="customer@example.com", + customer_id="CUSTOMER_ID", + ), + transactions=OrderTransactionRequest( + payments=[ + OrderPaymentRequest( + amount="100.00", + payment_method=OrderPaymentMethodRequest( + id="master", + type="credit_card", + token="CARD_TOKEN", + installments=1, + ), + automatic_payments=OrderAutomaticPayments( + payment_profile_id="PROFILE_ID", + retries=3, + schedule_date="2026-08-01T00:00:00.000-04:00", + due_date="2026-08-05T00:00:00.000-04:00", + ), + stored_credential=OrderStoredCredential( + payment_initiator="merchant", + reason="recurring", + store_payment_method=False, + first_payment=False, + previous_transaction_reference="PREV_TX_ID", + ), + subscription_data=OrderSubscriptionData( + invoice_id="INV-002", + billing_date="2026-07-27", + subscription_sequence=OrderSubscriptionSequence( + number=2, total=12 + ), + invoice_period=OrderInvoicePeriod( + type="monthly", period=1 + ), + ), + ) + ] + ), + integration_data=OrderIntegrationData( + integrator_id="INTEGRATOR_ID", + sponsor=OrderSponsor(id="SPONSOR_ID"), + ), + ) + result = sdk.order().create(typed) + self.assertEqual(result["status"], 201) + + sent = json.loads(http.last_data) + payment = sent["transactions"]["payments"][0] + + # payment_method + self.assertEqual(payment["payment_method"]["id"], "master") + self.assertEqual(payment["payment_method"]["token"], "CARD_TOKEN") + + # automatic_payments + ap = payment["automatic_payments"] + self.assertEqual(ap["payment_profile_id"], "PROFILE_ID") + self.assertEqual(ap["retries"], 3) + self.assertEqual(ap["schedule_date"], "2026-08-01T00:00:00.000-04:00") + self.assertEqual(ap["due_date"], "2026-08-05T00:00:00.000-04:00") + + # stored_credential + sc = payment["stored_credential"] + self.assertEqual(sc["payment_initiator"], "merchant") + self.assertEqual(sc["reason"], "recurring") + self.assertFalse(sc["store_payment_method"]) + self.assertFalse(sc["first_payment"]) + self.assertEqual(sc["previous_transaction_reference"], "PREV_TX_ID") + + # subscription_data + sub = payment["subscription_data"] + self.assertEqual(sub["invoice_id"], "INV-002") + self.assertEqual(sub["billing_date"], "2026-07-27") + self.assertEqual(sub["subscription_sequence"], {"number": 2, "total": 12}) + self.assertEqual(sub["invoice_period"], {"type": "monthly", "period": 1}) + + # integration_data + integ = sent["integration_data"] + self.assertEqual(integ["integrator_id"], "INTEGRATOR_ID") + self.assertEqual(integ["sponsor"], {"id": "SPONSOR_ID"}) + + def test_typed_transactions_and_dict_transactions_produce_same_json(self): + """Typed OrderTransactionRequest and equivalent dict produce identical JSON.""" + sdk_typed, http_typed = _make_sdk() + sdk_dict, http_dict = _make_sdk() + + typed_request = OrderCreateRequest( + type="online", + total_amount="50.00", + transactions=OrderTransactionRequest( + payments=[ + OrderPaymentRequest( + amount="50.00", + payment_method=OrderPaymentMethodRequest( + id="visa", type="credit_card", installments=1 + ), + automatic_payments=OrderAutomaticPayments( + payment_profile_id="PROF-1", + ), + ) + ] + ), + ) + + dict_request = { + "type": "online", + "total_amount": "50.00", + "transactions": { + "payments": [{ + "amount": "50.00", + "payment_method": { + "id": "visa", "type": "credit_card", "installments": 1 + }, + "automatic_payments": {"payment_profile_id": "PROF-1"}, + }] + }, + } + + sdk_typed.order().create(typed_request) + sdk_dict.order().create(dict_request) + + self.assertEqual( + json.loads(http_typed.last_data), + json.loads(http_dict.last_data), + ) + + +if __name__ == "__main__": + unittest.main()