From afaa74678c0053c480d41e19a578238871ff7663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Rodrigues?= Date: Wed, 14 Oct 2026 12:19:03 -0300 Subject: [PATCH 1/2] feat: estorno v3 (POST /v2/charges/{uuid}/refunds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona create_refund com x-idempotency-key e refunds[] na cobrança. Os endpoints antigos de estorno ficam marcados como depreciados, sem remoção. --- barte/__init__.py | 2 + barte/client.py | 42 ++++++++++++++- barte/models.py | 17 +++++- tests/test_client.py | 124 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 171 insertions(+), 14 deletions(-) diff --git a/barte/__init__.py b/barte/__init__.py index 9653cfc..017bc36 100644 --- a/barte/__init__.py +++ b/barte/__init__.py @@ -2,6 +2,7 @@ from .models import ( CardToken, Charge, + ChargeRefund, CreateSellerRequest, CreateSellerResponse, Customer, @@ -16,6 +17,7 @@ __all__ = [ "BarteClient", "Charge", + "ChargeRefund", "CardToken", "Refund", "PixCharge", diff --git a/barte/client.py b/barte/client.py index 078c272..7f925b4 100644 --- a/barte/client.py +++ b/barte/client.py @@ -1,3 +1,4 @@ +import warnings from dataclasses import asdict from decimal import Decimal from typing import Any, Dict, List, Optional, Union @@ -15,6 +16,7 @@ CardToken, Charge, ChargeList, + ChargeRefund, CreateSellerRequest, CreateSellerResponse, ErrorResponse, @@ -28,6 +30,10 @@ ) +def _deprecated(old: str, new: str) -> None: + warnings.warn(f"{old} is deprecated, use {new}", DeprecationWarning, stacklevel=3) + + class BarteClient: VALID_ENVIRONMENTS = ["production", "sandbox"] _instance = None @@ -92,6 +98,7 @@ def _request( path: str, params: Optional[Dict[str, Any]] = None, json: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Union[Dict[str, Any], List[Any], None]: """ Private method to centralize HTTP requests. @@ -101,6 +108,7 @@ def _request( path: API endpoint path (e.g., '/v2/orders') params: Query parameters for GET requests. json: JSON body for POST, PATCH requests. + headers: Per-request headers, merged over the session headers. Returns: The response JSON as a dictionary or list. @@ -111,7 +119,8 @@ def _request( without a structured error response. """ url = f"{self.base_url}{path}" - response = self.session.request(method, url, params=params, json=json) + extra = {"headers": headers} if headers else {} + response = self.session.request(method, url, params=params, json=json, **extra) if response.status_code == 204: return None @@ -227,6 +236,12 @@ def refund_charge(self, charge_id: str, as_fraud: Optional[bool] = False) -> Cha Raises: BarteError: When the API returns an error response with Barte error codes. """ + _deprecated("refund_charge", "create_refund") + return self._refund_charge(charge_id, as_fraud) + + def _refund_charge( + self, charge_id: str, as_fraud: Optional[bool] = False + ) -> Charge: json_response = self._request( "PATCH", f"/v2/charges/{charge_id}/refund", json={"asFraud": as_fraud} ) @@ -240,6 +255,7 @@ def partial_refund_charge( Raises: BarteError: When the API returns an error response with Barte error codes. """ + _deprecated("partial_refund_charge", "create_refund") json_response = self._request( "PATCH", f"/v2/charges/partial-refund/{charge_id}", json={"value": value} ) @@ -250,12 +266,36 @@ def partial_refund_charge( def get_refund(self, charge_id: str) -> List[Refund]: """Get refund detail""" + _deprecated("get_refund", "Charge.refunds from get_charge") json_response = self._request("GET", f"/v2/charges/partial-refund/{charge_id}") return [ from_dict(data_class=Refund, data=item, config=DACITE_CONFIG) for item in json_response ] + def create_refund( + self, charge_id: str, amount: Decimal, idempotency_key: str + ) -> ChargeRefund: + """Refund a charge, total or partial (refund v3) + + Raises: + ValueError: If the idempotency key is empty or the amount has more than two decimal places. + BarteError: When the API returns an error response with Barte error codes. + """ + if not idempotency_key: + raise ValueError("idempotency_key is required") + if round(amount, 2) != amount: + raise ValueError("amount must have at most two decimal places") + json_response = self._request( + "POST", + f"/v2/charges/{charge_id}/refunds", + json={"amount": float(amount)}, + headers={"x-idempotency-key": idempotency_key}, + ) + return from_dict( + data_class=ChargeRefund, data=json_response, config=DACITE_CONFIG + ) + def get_installments( self, amount: Decimal, max_installments: int ) -> List[InstallmentOption]: diff --git a/barte/models.py b/barte/models.py index b77d318..1bebd5b 100644 --- a/barte/models.py +++ b/barte/models.py @@ -57,6 +57,17 @@ class ChargerCustomer: alternativeEmail: Optional[str] +@dataclass +class ChargeRefund: + uuid: Optional[str] = None + amount: Optional[float] = None + status: Optional[str] = None + createdAt: Optional[datetime] = None + date: Optional[datetime] = None + idempotencyKey: Optional[str] = None + errorReason: Optional[str] = None + + @dataclass class Charge: uuid: str @@ -70,11 +81,13 @@ class Charge: authorizationCode: Optional[str] authorizationNsu: Optional[str] interestAmount: Optional[float] + refunds: Optional[List[ChargeRefund]] def refund(self, as_fraud: Optional[bool] = False) -> "Refund": - from .client import BarteClient + from .client import BarteClient, _deprecated - return BarteClient.get_instance().refund_charge(self.uuid, as_fraud) + _deprecated("Charge.refund", "BarteClient.create_refund") + return BarteClient.get_instance()._refund_charge(self.uuid, as_fraud) def cancel(self) -> "Charge": from .client import BarteClient diff --git a/tests/test_client.py b/tests/test_client.py index 80a8c6a..6b3c05e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,6 +11,7 @@ BarteClient, CardToken, Charge, + ChargeRefund, PartialRefund, PixCharge, ThreeDsSession, @@ -843,9 +844,10 @@ def test_refund_charge(self, mock_request, barte_client): mock_response_obj.raise_for_status = Mock() mock_request.return_value = mock_response_obj - refund = barte_client.refund_charge( - "d54f6553-8bcf-4376-a995-aaffb6d29492", as_fraud=False - ) + with pytest.warns(DeprecationWarning): + refund = barte_client.refund_charge( + "d54f6553-8bcf-4376-a995-aaffb6d29492", as_fraud=False + ) assert isinstance(refund, Charge) assert refund.uuid == "d54f6553-8bcf-4376-a995-aaffb6d29492" assert refund.value == 23.00 @@ -871,6 +873,7 @@ def test_get_charge(self, mock_request, barte_client, mock_charge_response): assert charge.value == 1000.00 assert charge.customer.name == "John Doe" assert isinstance(charge.paidDate, datetime) + assert charge.refunds is None mock_request.assert_called_once_with( "GET", @@ -1022,9 +1025,10 @@ def test_partial_refund(self, mock_request, barte_client): refund_value = 10 - refund = barte_client.partial_refund_charge( - "d54f6553-8bcf-4376-a995-aaffb6d29492", value=refund_value - ) + with pytest.warns(DeprecationWarning): + refund = barte_client.partial_refund_charge( + "d54f6553-8bcf-4376-a995-aaffb6d29492", value=refund_value + ) assert isinstance(refund[0], PartialRefund) assert refund[0].uuid == "d54f6553-8bcf-4376-a995-aaffb6d29492" @@ -1049,7 +1053,8 @@ def test_get_refund_detail(self, mock_request, barte_client): mock_response_obj.raise_for_status = Mock() mock_request.return_value = mock_response_obj - refund = barte_client.get_refund(refund_uuid) + with pytest.warns(DeprecationWarning): + refund = barte_client.get_refund(refund_uuid) assert isinstance(refund[0], PartialRefund) assert refund[0].uuid == refund_uuid @@ -1092,7 +1097,7 @@ def test_refund_charge_with_error( mock_request.return_value.json.return_value = mock_refund_error_response mock_request.return_value.raise_for_status = Mock() - with pytest.raises(BarteError) as exc_info: + with pytest.warns(DeprecationWarning), pytest.raises(BarteError) as exc_info: barte_client.refund_charge("d54f6553-8bcf-4376-a995-aaffb6d29492") assert exc_info.value.code == "BAR-7010" @@ -1107,7 +1112,7 @@ def test_partial_refund_charge_with_error( mock_request.return_value.json.return_value = mock_refund_error_response mock_request.return_value.raise_for_status = Mock() - with pytest.raises(BarteError) as exc_info: + with pytest.warns(DeprecationWarning), pytest.raises(BarteError) as exc_info: barte_client.partial_refund_charge( "d54f6553-8bcf-4376-a995-aaffb6d29492", value=Decimal("10.00") ) @@ -1126,7 +1131,7 @@ def test_refund_charge_with_error_and_charge_uuid( ) mock_request.return_value.raise_for_status = Mock() - with pytest.raises(BarteError) as exc_info: + with pytest.warns(DeprecationWarning), pytest.raises(BarteError) as exc_info: barte_client.refund_charge("d54f6553-8bcf-4376-a995-aaffb6d29492") assert exc_info.value.code == "BAR-7010" @@ -1225,7 +1230,7 @@ def test_partial_refund_charge_with_list_error_response( ) mock_request.return_value.raise_for_status = Mock() - with pytest.raises(BarteError) as exc_info: + with pytest.warns(DeprecationWarning), pytest.raises(BarteError) as exc_info: barte_client.partial_refund_charge( "d54f6553-8bcf-4376-a995-aaffb6d29492", value=Decimal("101.00") ) @@ -1235,3 +1240,100 @@ def test_partial_refund_charge_with_list_error_response( exc_info.value.message == "Valor solicitado é maior que o valor disponível na cobrança." ) + + @patch("barte.client.requests.Session.request") + def test_create_refund(self, mock_request, barte_client): + """Test requesting a refund on the v3 endpoint""" + mock_request.return_value.json.return_value = { + "uuid": "cebd9a00-620a-40cf-b9a6-5f6eb55dc563", + "amount": 10.0, + "status": "PROCESSING", + "createdAt": "2026-10-13T20:19:43Z", + "idempotencyKey": "estorno-1", + } + mock_request.return_value.raise_for_status = Mock() + + refund = barte_client.create_refund( + "8b6b2ddc-7ccb-4d1f-8832-ef0adc62ed31", Decimal("10.00"), "estorno-1" + ) + + assert isinstance(refund, ChargeRefund) + assert refund.uuid == "cebd9a00-620a-40cf-b9a6-5f6eb55dc563" + assert refund.status == "PROCESSING" + assert refund.idempotencyKey == "estorno-1" + assert isinstance(refund.createdAt, datetime) + mock_request.assert_called_once_with( + "POST", + f"{barte_client.base_url}/v2/charges/8b6b2ddc-7ccb-4d1f-8832-ef0adc62ed31/refunds", + params=None, + json={"amount": 10.0}, + headers={"x-idempotency-key": "estorno-1"}, + ) + + @pytest.mark.parametrize( + ("amount", "key"), + [(Decimal("10.00"), ""), (Decimal("10.00"), None), (Decimal("10.005"), "k")], + ) + def test_create_refund_rejects_invalid_input(self, barte_client, amount, key): + """Test create_refund refuses an empty key or more than two decimals""" + with pytest.raises(ValueError): + barte_client.create_refund( + "8b6b2ddc-7ccb-4d1f-8832-ef0adc62ed31", amount, key + ) + + @patch("barte.client.requests.Session.request") + def test_create_refund_conflict_keeps_http_status(self, mock_request, barte_client): + """Test the 409 of an idempotency conflict reaches the caller""" + mock_response = Mock() + mock_response.status_code = 409 + mock_response.json.return_value = { + "errors": [ + { + "code": "BAR-0466", + "title": "Erro no fluxo de assinatura", + "description": "A chave de idempotência já foi utilizada nesta cobrança com outro valor.", + } + ], + "metadata": { + "totalRecords": 1, + "totalPages": 1, + "requestDatetime": "2026-10-13T20:19:44Z", + }, + } + mock_request.return_value = mock_response + + with pytest.raises(BarteError) as exc_info: + barte_client.create_refund( + "8b6b2ddc-7ccb-4d1f-8832-ef0adc62ed31", Decimal("20.00"), "estorno-1" + ) + + assert exc_info.value.code == "BAR-0466" + assert exc_info.value.response.status_code == 409 + + @patch("barte.client.requests.Session.request") + def test_get_charge_with_refunds( + self, mock_request, barte_client, mock_charge_response + ): + """Test get_charge exposes refunds[], including items with null fields""" + mock_request.return_value.json.return_value = { + **mock_charge_response, + "refunds": [ + { + "uuid": "cebd9a00-620a-40cf-b9a6-5f6eb55dc563", + "date": "2026-10-13", + "createdAt": "2026-10-13T20:19:43Z", + "amount": 10.0, + "status": "SUCCESS", + "idempotencyKey": "estorno-1", + }, + {"uuid": None, "amount": 5.0, "status": None, "idempotencyKey": None}, + ], + } + mock_request.return_value.raise_for_status = Mock() + + charge = barte_client.get_charge("8b6b2ddc-7ccb-4d1f-8832-ef0adc62ed31") + + assert [(r.idempotencyKey, r.status, r.amount) for r in charge.refunds] == [ + ("estorno-1", "SUCCESS", 10.0), + (None, None, 5.0), + ] From f878121bbdd6958240ec6288b4d276b4021b43d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Rodrigues?= Date: Wed, 14 Oct 2026 12:39:03 -0300 Subject: [PATCH 2/2] fix: refunds como argumento nomeado em Charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem default, o campo virava obrigatório nos construtores de Charge e PixCharge. Com kw_only e default None, as chamadas antigas continuam valendo, inclusive por posição. --- barte/models.py | 4 ++-- tests/test_client.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/barte/models.py b/barte/models.py index 1bebd5b..594016d 100644 --- a/barte/models.py +++ b/barte/models.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum from typing import List, Literal, Optional @@ -81,7 +81,7 @@ class Charge: authorizationCode: Optional[str] authorizationNsu: Optional[str] interestAmount: Optional[float] - refunds: Optional[List[ChargeRefund]] + refunds: Optional[List[ChargeRefund]] = field(default=None, kw_only=True) def refund(self, as_fraud: Optional[bool] = False) -> "Refund": from .client import BarteClient, _deprecated diff --git a/tests/test_client.py b/tests/test_client.py index 6b3c05e..3b0845a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,6 +19,7 @@ from barte.exceptions import BarteError from barte.models import ( DACITE_CONFIG, + ChargerCustomer, CreateSellerAccount, CreateSellerAccountDetails, CreateSellerAddress, @@ -1337,3 +1338,33 @@ def test_get_charge_with_refunds( ("estorno-1", "SUCCESS", 10.0), (None, None, 5.0), ] + + def test_charge_constructors_keep_positional_signature(self): + """Test refunds is keyword-only, so existing positional calls still work""" + customer = ChargerCustomer( + uuid="", + document="19340911032", + type="CPF", + name="John Doe", + email="john@example.com", + phone="11999999999", + alternativeEmail=None, + ) + pix = PixCharge( + "7a384917-e73e-466e-b90d-8c9f04e7fa9f", + "Teste", + datetime(2025, 2, 12), + 3.00, + "PIX", + "SCHEDULED", + customer, + None, + None, + None, + None, + "pix-code", + "pix-image", + ) + + assert pix.pixCode == "pix-code" + assert pix.refunds is None