Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions barte/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .models import (
CardToken,
Charge,
ChargeRefund,
CreateSellerRequest,
CreateSellerResponse,
Customer,
Expand All @@ -16,6 +17,7 @@
__all__ = [
"BarteClient",
"Charge",
"ChargeRefund",
"CardToken",
"Refund",
"PixCharge",
Expand Down
42 changes: 41 additions & 1 deletion barte/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from dataclasses import asdict
from decimal import Decimal
from typing import Any, Dict, List, Optional, Union
Expand All @@ -15,6 +16,7 @@
CardToken,
Charge,
ChargeList,
ChargeRefund,
CreateSellerRequest,
CreateSellerResponse,
ErrorResponse,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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}
)
Expand All @@ -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}
)
Expand All @@ -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]:
Expand Down
19 changes: 16 additions & 3 deletions barte/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Comment thread
FabioMR marked this conversation as resolved.
Expand All @@ -70,11 +81,13 @@ class Charge:
authorizationCode: Optional[str]
authorizationNsu: Optional[str]
interestAmount: Optional[float]
refunds: Optional[List[ChargeRefund]] = field(default=None, kw_only=True)

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
Expand Down
155 changes: 144 additions & 11 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
BarteClient,
CardToken,
Charge,
ChargeRefund,
PartialRefund,
PixCharge,
ThreeDsSession,
)
from barte.exceptions import BarteError
from barte.models import (
DACITE_CONFIG,
ChargerCustomer,
CreateSellerAccount,
CreateSellerAccountDetails,
CreateSellerAddress,
Expand Down Expand Up @@ -843,9 +845,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
Expand All @@ -871,6 +874,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",
Expand Down Expand Up @@ -1022,9 +1026,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"
Expand All @@ -1049,7 +1054,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
Expand Down Expand Up @@ -1092,7 +1098,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"
Expand All @@ -1107,7 +1113,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")
)
Expand All @@ -1126,7 +1132,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"
Expand Down Expand Up @@ -1225,7 +1231,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")
)
Expand All @@ -1235,3 +1241,130 @@ 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),
]

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
Loading