diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d5e6fc..2d25d71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..611c4b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.2.0] - 2026-05-27 + +### Added + +- **OAuth**: authorization URL builder, token exchange, and token refresh + (`sdk.oauth().get_authorization_url()`, `.create()`, `.refresh()`). + Enables marketplace and platform integrations to operate on behalf of + other sellers via the OAuth 2.0 authorization code flow. + Endpoints: `POST /oauth/token`. + +- **Point**: in-person payment intent management for MercadoPago Point + devices (`sdk.point().get_devices()`, `.create()`, `.get()`, `.cancel()`). + Enables card-present transactions through physical Point card readers. + Endpoints: `GET /point/integration-api/devices`, + `POST /point/integration-api/devices/{device_id}/payment-intents`, + `GET /point/integration-api/payment-intents/{id}`, + `DELETE /point/integration-api/devices/{device_id}/payment-intents/{id}`. + Note: `change_operating_mode` (PATCH) is not included; requires HttpClient + PATCH support. + +- **Invoice**: retrieval and search of subscription billing invoices + (`sdk.invoice().get()`, `.search()`). Enables monitoring of authorized + payments generated by PreApproval billing cycles. + Endpoints: `GET /authorized_payments/{id}`, + `GET /authorized_payments/search`. diff --git a/mercadopago/config/config.py b/mercadopago/config/config.py index 36d74c8..1c79773 100644 --- a/mercadopago/config/config.py +++ b/mercadopago/config/config.py @@ -26,7 +26,7 @@ class Config: def __init__(self): """Builds version-dependent values (user_agent, tracking_id).""" - self.__version = "3.1.1" + self.__version = "3.2.0" self.__user_agent = "MercadoPago Python SDK v" + self.__version self.__product_id = "bc32bpftrpp001u8nhlg" self.__tracking_id = "platform:" + platform.python_version() diff --git a/mercadopago/examples/invoice/get_invoice.py b/mercadopago/examples/invoice/get_invoice.py new file mode 100644 index 0000000..244dfcb --- /dev/null +++ b/mercadopago/examples/invoice/get_invoice.py @@ -0,0 +1,29 @@ +from mercadopago import SDK + + +def main(): + # Initialize the SDK with your access token + access_token = "" + sdk = SDK(access_token) + + # Search invoices for a specific subscription (preapproval) + filters = { + "preapproval_id": "", + "limit": 10, + } + + try: + invoices = sdk.invoice().search(filters=filters) + print("Invoices found:", invoices["response"]) + + # Retrieve a specific invoice by ID + if invoices["response"].get("results"): + invoice_id = invoices["response"]["results"][0]["id"] + invoice = sdk.invoice().get(invoice_id) + print("Invoice details:", invoice["response"]) + except Exception as e: + print("Error:", e) + + +if __name__ == "__main__": + main() diff --git a/mercadopago/examples/oauth/create_token.py b/mercadopago/examples/oauth/create_token.py new file mode 100644 index 0000000..7170f9c --- /dev/null +++ b/mercadopago/examples/oauth/create_token.py @@ -0,0 +1,33 @@ +from mercadopago import SDK + + +def main(): + # Initialize the SDK with your marketplace access token + access_token = "" + sdk = SDK(access_token) + + # Step 1: Build the authorization URL and redirect the seller to it + auth_url = sdk.oauth().get_authorization_url( + app_id="", + redirect_uri="https://yourapp.com/callback", + random_id="", + ) + print("Redirect seller to:", auth_url) + + # Step 2: After the seller authorizes, exchange the received code for tokens + oauth_object = { + "client_secret": access_token, + "code": "", + "redirect_uri": "https://yourapp.com/callback", + "grant_type": "authorization_code", + } + + try: + response = sdk.oauth().create(oauth_object) + print("Token created successfully:", response["response"]) + except Exception as e: + print("Error:", e) + + +if __name__ == "__main__": + main() diff --git a/mercadopago/examples/point/create_payment_intent.py b/mercadopago/examples/point/create_payment_intent.py new file mode 100644 index 0000000..68a0623 --- /dev/null +++ b/mercadopago/examples/point/create_payment_intent.py @@ -0,0 +1,37 @@ +from mercadopago import SDK + + +def main(): + # Initialize the SDK with your access token + access_token = "" + sdk = SDK(access_token) + + # List available Point devices to get a device_id + devices = sdk.point().get_devices() + print("Available devices:", devices["response"]) + + # Create a payment intent on a specific device + device_id = "" + payment_intent_object = { + "amount": 1500, + "description": "Product purchase", + "payment": { + "installments": 1, + "type": "credit_card", + }, + } + + try: + response = sdk.point().create(device_id, payment_intent_object) + print("Payment intent created:", response["response"]) + + # Retrieve the payment intent status + payment_intent_id = response["response"]["id"] + status = sdk.point().get(payment_intent_id) + print("Payment intent status:", status["response"]) + except Exception as e: + print("Error:", e) + + +if __name__ == "__main__": + main() diff --git a/mercadopago/resources/__init__.py b/mercadopago/resources/__init__.py index 0b58843..a4e4baf 100644 --- a/mercadopago/resources/__init__.py +++ b/mercadopago/resources/__init__.py @@ -13,11 +13,14 @@ from mercadopago.resources.customer import Customer from mercadopago.resources.disbursement_refund import DisbursementRefund from mercadopago.resources.identification_type import IdentificationType +from mercadopago.resources.invoice import Invoice from mercadopago.resources.merchant_order import MerchantOrder +from mercadopago.resources.oauth import OAuth from mercadopago.resources.order import Order from mercadopago.resources.payment import Payment from mercadopago.resources.payment_methods import PaymentMethods from mercadopago.resources.plan import Plan +from mercadopago.resources.point import Point from mercadopago.resources.preapproval import PreApproval from mercadopago.resources.preference import Preference from mercadopago.resources.refund import Refund @@ -34,11 +37,14 @@ 'DisbursementRefund', 'HttpClient', 'IdentificationType', + 'Invoice', 'MerchantOrder', + 'OAuth', 'Order', 'Payment', 'PaymentMethods', 'Plan', + 'Point', 'PreApproval', 'Preference', 'Refund', diff --git a/mercadopago/resources/invoice.py b/mercadopago/resources/invoice.py new file mode 100644 index 0000000..3305f53 --- /dev/null +++ b/mercadopago/resources/invoice.py @@ -0,0 +1,62 @@ +"""Invoice resource for the MercadoPago Subscriptions API. + +Wraps ``/authorized_payments`` endpoints to retrieve and search invoices +generated by subscription (pre-approval) billing cycles. Each invoice +represents a scheduled charge attempt against the subscriber's payment +method. + +`API reference +`_ +""" +from mercadopago.core import MPBase + + +class Invoice(MPBase): + """Provides read access to subscription invoices (authorized payments). + + Each invoice corresponds to a billing cycle of a + :class:`~mercadopago.resources.preapproval.PreApproval` and tracks + the charge amount, status, retry attempts, and the resulting + payment. Use :meth:`get` and :meth:`search` to monitor billing + cycles and their outcomes. + """ + + def get(self, invoice_id, request_options=None): + """Retrieves a single invoice (authorized payment) by its ID. + + Args: + invoice_id: Unique invoice identifier assigned by + MercadoPago. + request_options: Per-call configuration overrides. + + Returns: + dict: Full invoice object including ``status``, + ``transaction_amount``, ``preapproval_id``, and + associated ``payment`` details. + + Reference: https://www.mercadopago.com/developers/en/reference/online-payments/subscriptions/get-authorized-payment/get + """ + return self._get( + uri="/authorized_payments/" + str(invoice_id), + request_options=request_options, + ) + + def search(self, filters=None, request_options=None): + """Searches invoices matching the given filters. + + Args: + filters: Query-string parameters such as ``preapproval_id``, + ``status``, ``payer_id``, ``offset``, and ``limit``. + request_options: Per-call configuration overrides. + + Returns: + dict: Paginated result with ``paging`` metadata and a + ``results`` list of matching invoices. + + Reference: https://www.mercadopago.com/developers/en/reference/online-payments/subscriptions/authorized-payment-search/get + """ + return self._get( + uri="/authorized_payments/search", + filters=filters, + request_options=request_options, + ) diff --git a/mercadopago/resources/oauth.py b/mercadopago/resources/oauth.py new file mode 100644 index 0000000..a51c22a --- /dev/null +++ b/mercadopago/resources/oauth.py @@ -0,0 +1,119 @@ +"""OAuth resource for the MercadoPago Authorization API. + +Wraps the OAuth 2.0 authorization code flow used in marketplace and +platform integrations to operate on behalf of other sellers. + +`API reference +`_ +""" +from urllib.parse import urlencode + +from mercadopago.core import MPBase + + +_AUTH_URL = "https://auth.mercadopago.com/authorization" + + +class OAuth(MPBase): + """Manages the OAuth 2.0 authorization code flow. + + Use this resource when your application needs to operate on behalf + of other MercadoPago sellers (marketplace or platform scenarios). + The flow involves redirecting the seller to the authorization URL, + receiving an authorization code, and exchanging it for access and + refresh tokens. + """ + + def get_authorization_url(self, app_id, redirect_uri, random_id): + """Builds the MercadoPago authorization URL for the OAuth flow. + + Redirect the seller to this URL to start the authorization + process. After granting permission, MercadoPago redirects back + to *redirect_uri* with a ``code`` query parameter. + + Args: + app_id: Your MercadoPago application's client ID. + redirect_uri: URI where MercadoPago sends the seller after + authorization. + random_id: CSRF-protection state parameter; should be a + unique, unpredictable value per authorization request. + + Returns: + str: Full authorization URL with query parameters. + + Reference: https://www.mercadopago.com/developers/en/docs/security/oauth/creation + """ + params = { + "client_id": app_id, + "response_type": "code", + "platform_id": "mp", + "state": random_id, + "redirect_uri": redirect_uri, + } + return _AUTH_URL + "?" + urlencode(params) + + def create(self, oauth_object, request_options=None): + """Exchanges an authorization code for an access token. + + Call this after receiving the ``code`` parameter in your + *redirect_uri* callback. The returned access token can be used + to make API requests on behalf of the authorizing seller. + + Args: + oauth_object: Dict with the authorization request fields: + ``client_secret`` (your access token), ``code`` + (authorization code received in the callback), + ``redirect_uri`` (must match the one used in + :meth:`get_authorization_url`), and ``grant_type`` + (``"authorization_code"``). + request_options: Per-call configuration overrides. + + Raises: + ValueError: If *oauth_object* is not a ``dict``. + + Returns: + dict: Token response including ``access_token``, + ``refresh_token``, and ``expires_in``. + + Reference: https://www.mercadopago.com/developers/en/reference/authentication/oauth/_oauth_token/post + """ + if not isinstance(oauth_object, dict): + raise ValueError("Param oauth_object must be a Dictionary") + + return self._post( + uri="/oauth/token", + data=oauth_object, + request_options=request_options, + ) + + def refresh(self, oauth_object, request_options=None): + """Refreshes an expired access token. + + Use this to extend the seller's session without requiring them + to re-authorize. The *refresh_token* is obtained from the + initial :meth:`create` response. + + Args: + oauth_object: Dict with the refresh request fields: + ``client_secret`` (your access token), + ``refresh_token`` (the token to refresh), and + ``grant_type`` (``"refresh_token"``). + request_options: Per-call configuration overrides. + + Raises: + ValueError: If *oauth_object* is not a ``dict``. + + Returns: + dict: New token response with a fresh ``access_token`` and + ``refresh_token``. + + Reference: https://www.mercadopago.com/developers/en/reference/authentication/oauth/_oauth_token/post + """ + if not isinstance(oauth_object, dict): + raise ValueError("Param oauth_object must be a Dictionary") + + return self._post( + uri="/oauth/token", + data=oauth_object, + request_options=request_options, + ) diff --git a/mercadopago/resources/point.py b/mercadopago/resources/point.py new file mode 100644 index 0000000..545df88 --- /dev/null +++ b/mercadopago/resources/point.py @@ -0,0 +1,121 @@ +"""Point resource for the MercadoPago Point Integration API. + +Wraps ``/point/integration-api`` endpoints for in-person payment +processing through MercadoPago Point devices (card readers). + +Supported operations: list devices, create payment intent, get payment +intent, and cancel payment intent. + +Note: The ``change_operating_mode`` operation (PATCH +``/point/integration-api/devices/{device_id}``) is not included because +the Python SDK HTTP client does not currently expose a PATCH method. + +`API reference +`_ +""" +from mercadopago.core import MPBase + + +class Point(MPBase): + """Manages payment intents on MercadoPago Point (POS) devices. + + Enables in-person payment processing by creating payment intents + that are sent to a physical Point device for the buyer to complete + the transaction by inserting or tapping their card. + """ + + def get_devices(self, filters=None, request_options=None): + """Lists Point devices linked to the authenticated account. + + Args: + filters: Optional query-string parameters such as + ``store_id`` and ``pos_id``. + request_options: Per-call configuration overrides. + + Returns: + dict: List of devices with their identifiers, operating + modes, and statuses. + + Reference: https://www.mercadopago.com/developers/en/reference/in-person-payments/point/devices/list-devices/get + """ + return self._get( + uri="/point/integration-api/devices", + filters=filters, + request_options=request_options, + ) + + def create(self, device_id, payment_intent_object, request_options=None): + """Creates a payment intent on a specific Point device. + + The payment intent is sent to the physical device, where the + buyer completes the transaction. + + Args: + device_id: Unique identifier of the target Point device. + payment_intent_object: Dict describing the payment intent, + including ``amount``, ``description``, and ``payment`` + method details. + request_options: Per-call configuration overrides. + + Raises: + ValueError: If *payment_intent_object* is not a ``dict``. + + Returns: + dict: Created payment intent including its ``id`` and + ``state``. + + Reference: https://www.mercadopago.com/developers/en/reference/in-person-payments/point/orders/create-order/post + """ + if not isinstance(payment_intent_object, dict): + raise ValueError("Param payment_intent_object must be a Dictionary") + + return self._post( + uri="/point/integration-api/devices/" + str(device_id) + "/payment-intents", + data=payment_intent_object, + request_options=request_options, + ) + + def get(self, payment_intent_id, request_options=None): + """Retrieves the current state of a payment intent by its ID. + + Use this to check whether the buyer has completed, cancelled, + or is still processing the payment on the device. + + Args: + payment_intent_id: Unique identifier of the payment intent. + request_options: Per-call configuration overrides. + + Returns: + dict: Payment intent details including its ``state`` and, + when completed, the associated ``payment_id``. + + Reference: https://www.mercadopago.com/developers/en/reference/in-person-payments/point/orders/get-order/get + """ + return self._get( + uri="/point/integration-api/payment-intents/" + str(payment_intent_id), + request_options=request_options, + ) + + def cancel(self, device_id, payment_intent_id, request_options=None): + """Cancels a pending payment intent on a specific device. + + Use this to abort a transaction before the buyer completes the + payment on the device. + + Args: + device_id: Unique identifier of the Point device holding + the intent. + payment_intent_id: Unique identifier of the payment intent + to cancel. + request_options: Per-call configuration overrides. + + Returns: + dict: Cancellation confirmation. + + Reference: https://www.mercadopago.com/developers/en/reference/in-person-payments/point/orders/cancel-order/post + """ + return self._delete( + uri="/point/integration-api/devices/" + str(device_id) + + "/payment-intents/" + str(payment_intent_id), + request_options=request_options, + ) diff --git a/mercadopago/sdk.py b/mercadopago/sdk.py index e225556..1b0b899 100644 --- a/mercadopago/sdk.py +++ b/mercadopago/sdk.py @@ -24,11 +24,14 @@ Customer, DisbursementRefund, IdentificationType, + Invoice, MerchantOrder, + OAuth, Order, Payment, PaymentMethods, Plan, + Point, PreApproval, Preference, Refund, @@ -37,7 +40,7 @@ ) -class SDK: +class SDK: # pylint: disable=too-many-public-methods """Central client providing factory methods for every API resource. Each factory method (e.g. :meth:`payment`, :meth:`order`) returns a @@ -116,6 +119,21 @@ def identification_type(self, request_options=None): return IdentificationType(request_options is not None and request_options or self.request_options, self.http_client) + def invoice(self, request_options=None): + """Creates an :class:`Invoice` resource to retrieve subscription billing invoices.""" + return Invoice(request_options is not None and request_options + or self.request_options, self.http_client) + + def oauth(self, request_options=None): + """Creates an :class:`OAuth` resource for the OAuth 2.0 authorization code flow.""" + return OAuth(request_options is not None and request_options + or self.request_options, self.http_client) + + def point(self, request_options=None): + """Creates a :class:`Point` resource for in-person payments via Point devices.""" + return Point(request_options is not None and request_options + or self.request_options, self.http_client) + def merchant_order(self, request_options=None): """Creates a :class:`MerchantOrder` resource for Checkout Pro orders.""" return MerchantOrder(request_options is not None and request_options diff --git a/pyproject.toml b/pyproject.toml index 5cba398..b67467b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mercadopago" -version = "3.1.1" +version = "3.2.0" authors = [ {name = "Mercado Pago", email = "mp_sdk@mercadopago.com"} ] @@ -12,16 +12,15 @@ description = "Mercadopago SDK module for Payments integration" readme = "README.md" keywords = ["api", "mercadopago", "checkout", "payment in sdk integration", "lts"] license = {file = "LICENSE"} -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ - "requests>=2.32.4,<3" + "requests>=2.33.0,<3" ] classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/setup.cfg b/setup.cfg index 4a36bf8..54cd4bf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [pylint.MASTER] -py-version = 3.9 +py-version = 3.10 [pylint.FORMAT] max-line-length = 100 diff --git a/tests/test_invoice.py b/tests/test_invoice.py new file mode 100644 index 0000000..7860eba --- /dev/null +++ b/tests/test_invoice.py @@ -0,0 +1,27 @@ +""" + Module: test_invoice +""" +import os +import unittest +import mercadopago + + +class TestInvoice(unittest.TestCase): + """ + Test Module: Invoice + """ + sdk = mercadopago.SDK(os.environ['ACCESS_TOKEN']) + + def test_search_invoice(self): + """ + Test Function: Invoice search returns a valid HTTP response + """ + filters_invoice = { + "limit": 5 + } + invoices = self.sdk.invoice().search(filters=filters_invoice) + self.assertIn(invoices["status"], [200, 400, 401, 404]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..bc3d8af --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,47 @@ +""" + Module: test_oauth +""" +import os +import unittest +import mercadopago + + +class TestOAuth(unittest.TestCase): + """ + Test Module: OAuth + """ + sdk = mercadopago.SDK(os.environ['ACCESS_TOKEN']) + + def test_get_authorization_url(self): + """ + Test Function: OAuth get_authorization_url builds correct URL + """ + url = self.sdk.oauth().get_authorization_url( + app_id="TEST_APP_ID", + redirect_uri="https://example.com/callback", + random_id="csrf_state_token", + ) + self.assertIn("https://auth.mercadopago.com/authorization", url) + self.assertIn("client_id=TEST_APP_ID", url) + self.assertIn("response_type=code", url) + self.assertIn("redirect_uri=", url) + self.assertIn("state=csrf_state_token", url) + self.assertIn("platform_id=mp", url) + + def test_create_raises_on_invalid_param(self): + """ + Test Function: OAuth create raises ValueError for non-dict input + """ + with self.assertRaises(ValueError): + self.sdk.oauth().create("not_a_dict") + + def test_refresh_raises_on_invalid_param(self): + """ + Test Function: OAuth refresh raises ValueError for non-dict input + """ + with self.assertRaises(ValueError): + self.sdk.oauth().refresh("not_a_dict") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_order.py b/tests/test_order.py index 34445b9..14ccc5f 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -2,7 +2,6 @@ Module: test_order """ import os -import time import unittest import random from datetime import datetime, timezone, timedelta @@ -222,8 +221,9 @@ def test_process_order(self): def test_cancel_order(self): card_token_id = self.create_master_test_card() order_id = self.create_order_canceled_or_captured(card_token_id) - time.sleep(10) - order_canceled = self.sdk.order().cancel(order_id) + order_canceled = api_call_with_retry( + lambda: self.sdk.order().cancel(order_id), expected_status=200 + ) self.assertEqual(order_canceled["status"], 200) self.assertEqual(order_canceled["response"]["status"], "canceled") @@ -289,12 +289,13 @@ def test_partial_refund_transaction(self): ] } - sleep(6) - - transaction_refunded = self.sdk.order().refund_transaction(order_id, transaction_refund) - self.assertIn(transaction_refunded["status"], [ 201], + transaction_refunded = api_call_with_retry( + lambda: self.sdk.order().refund_transaction(order_id, transaction_refund), + expected_status=201 + ) + self.assertIn(transaction_refunded["status"], [201], f"Unexpected status code for refund: {transaction_refunded['status']}." - " Response: {transaction_refunded}") + f" Response: {transaction_refunded}") def test_refund_transaction(self): card_token_id = self.create_master_test_card() diff --git a/tests/test_point.py b/tests/test_point.py new file mode 100644 index 0000000..d2e3ab0 --- /dev/null +++ b/tests/test_point.py @@ -0,0 +1,31 @@ +""" + Module: test_point +""" +import os +import unittest +import mercadopago + + +class TestPoint(unittest.TestCase): + """ + Test Module: Point + """ + sdk = mercadopago.SDK(os.environ['ACCESS_TOKEN']) + + def test_get_devices(self): + """ + Test Function: Point get_devices returns a valid HTTP response + """ + devices = self.sdk.point().get_devices() + self.assertIn(devices["status"], [200, 400, 401, 403, 404]) + + def test_create_raises_on_invalid_param(self): + """ + Test Function: Point create raises ValueError for non-dict payment intent + """ + with self.assertRaises(ValueError): + self.sdk.point().create("device_123", "not_a_dict") + + +if __name__ == "__main__": + unittest.main()