Skip to content

Commit 264d02d

Browse files
authored
Merge pull request #50 from angelraph/feat/transfer-model
feat(models): implement Transfer model and TransferStatus enum
2 parents 475b970 + fe6635b commit 264d02d

4 files changed

Lines changed: 241 additions & 2 deletions

File tree

src/shade/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
ShadeError,
1717
SignatureVerificationError,
1818
)
19-
from .models import Merchant, ShadeObject
19+
from .models import Merchant, ShadeObject, Transfer, TransferStatus
2020

2121
__version__ = "0.1.0"
2222

@@ -39,6 +39,8 @@
3939
"SignatureVerificationError",
4040
"ShadeObject",
4141
"SyncHTTPClient",
42+
"Transfer",
43+
"TransferStatus",
4244
"config",
4345
"api_base",
4446
"environment",

src/shade/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"""
44
from .base import ShadeObject
55
from .merchant import Merchant
6+
from .transfer import Transfer, TransferStatus
67

7-
__all__ = ["Merchant", "ShadeObject"]
8+
__all__ = ["Merchant", "ShadeObject", "Transfer", "TransferStatus"]

src/shade/models/transfer.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Transfer model.
3+
4+
Represents a payout of funds from the merchant wallet to a destination
5+
address on the Stellar network. Field names are converted from ``camelCase``
6+
(backend/JSON) to ``snake_case`` (Python) via pydantic field aliases, matching
7+
the convention established by :class:`~shade.models.merchant.Merchant`.
8+
"""
9+
from __future__ import annotations
10+
11+
from datetime import datetime
12+
from decimal import Decimal
13+
from enum import Enum
14+
from typing import Optional
15+
16+
from pydantic import Field, field_validator
17+
from stellar_sdk.strkey import StrKey
18+
19+
from .base import ShadeObject
20+
21+
22+
class TransferStatus(str, Enum):
23+
"""Lifecycle status of a transfer."""
24+
25+
PENDING = "pending"
26+
PROCESSING = "processing"
27+
COMPLETED = "completed"
28+
FAILED = "failed"
29+
30+
31+
class Transfer(ShadeObject):
32+
"""A payout of funds from the merchant wallet to a destination address.
33+
34+
Build one from an API response with :meth:`ShadeObject.from_dict`, which
35+
maps camelCase JSON keys to the snake_case fields below. ``asset`` falls
36+
back to ``"XLM"`` when the API omits it (or sends it as ``null``), and
37+
``status`` is always coerced to a :class:`TransferStatus` member.
38+
"""
39+
40+
id: str
41+
source_address: str = Field(alias="sourceAddress")
42+
destination_address: str = Field(alias="destinationAddress")
43+
amount: Decimal
44+
asset: str = "XLM"
45+
status: TransferStatus
46+
stellar_tx_hash: Optional[str] = Field(default=None, alias="stellarTxHash")
47+
fee: Optional[Decimal] = None
48+
created_at: datetime = Field(alias="createdAt")
49+
50+
@field_validator("asset", mode="before")
51+
@classmethod
52+
def _default_asset(cls, value: object) -> object:
53+
# Covers both a missing key (pydantic would already default it) and an
54+
# API response that sends the key as an explicit null/empty string.
55+
# Other falsy-but-wrong types (e.g. False, 0) are left alone so
56+
# pydantic's normal type validation rejects them.
57+
if value is None or value == "":
58+
return "XLM"
59+
return value
60+
61+
@field_validator("source_address", "destination_address")
62+
@classmethod
63+
def _validate_stellar_address(cls, value: str) -> str:
64+
if not StrKey.is_valid_ed25519_public_key(value):
65+
raise ValueError(
66+
"must be a valid Stellar public key (starts with 'G', 56 characters)"
67+
)
68+
return value
69+
70+
@field_validator("amount")
71+
@classmethod
72+
def _amount_must_be_positive(cls, value: Decimal) -> Decimal:
73+
if value <= 0:
74+
raise ValueError("amount must be greater than 0")
75+
return value
76+
77+
@field_validator("fee")
78+
@classmethod
79+
def _fee_must_not_be_negative(cls, value: Optional[Decimal]) -> Optional[Decimal]:
80+
if value is not None and value < 0:
81+
raise ValueError("fee must not be negative")
82+
return value

tests/test_transfer.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
from datetime import datetime
2+
from decimal import Decimal
3+
4+
import pytest
5+
from stellar_sdk import Keypair
6+
7+
import shade
8+
from shade import InvalidRequestError, ShadeObject, Transfer, TransferStatus
9+
10+
SOURCE_ADDRESS = Keypair.random().public_key
11+
DESTINATION_ADDRESS = Keypair.random().public_key
12+
13+
14+
def _api_response(**overrides):
15+
"""A representative camelCase backend payload."""
16+
data = {
17+
"id": "trf_123",
18+
"sourceAddress": SOURCE_ADDRESS,
19+
"destinationAddress": DESTINATION_ADDRESS,
20+
"amount": "150.25",
21+
"asset": "USDC",
22+
"status": "pending",
23+
"stellarTxHash": None,
24+
"fee": "0.5",
25+
"createdAt": "2026-07-20T12:00:00Z",
26+
}
27+
data.update(overrides)
28+
return data
29+
30+
31+
def test_from_dict_maps_camelcase_to_snake_case():
32+
transfer = Transfer.from_dict(_api_response())
33+
34+
assert transfer.id == "trf_123"
35+
assert transfer.source_address == SOURCE_ADDRESS
36+
assert transfer.destination_address == DESTINATION_ADDRESS
37+
assert transfer.amount == Decimal("150.25")
38+
assert transfer.asset == "USDC"
39+
assert transfer.status == TransferStatus.PENDING
40+
assert transfer.stellar_tx_hash is None
41+
assert transfer.fee == Decimal("0.5")
42+
assert transfer.created_at == datetime.fromisoformat("2026-07-20T12:00:00+00:00")
43+
44+
45+
def test_amount_and_fee_are_decimal():
46+
transfer = Transfer.from_dict(_api_response(amount="99.99", fee="1.10"))
47+
assert isinstance(transfer.amount, Decimal)
48+
assert isinstance(transfer.fee, Decimal)
49+
50+
51+
def test_asset_defaults_to_xlm_when_absent():
52+
payload = _api_response()
53+
del payload["asset"]
54+
transfer = Transfer.from_dict(payload)
55+
assert transfer.asset == "XLM"
56+
57+
58+
def test_asset_defaults_to_xlm_when_null():
59+
transfer = Transfer.from_dict(_api_response(asset=None))
60+
assert transfer.asset == "XLM"
61+
62+
63+
def test_malformed_asset_is_rejected_not_defaulted():
64+
# False/0 are falsy but must not be silently coerced to "XLM" — they are
65+
# the wrong type and should surface as a validation error instead.
66+
with pytest.raises(InvalidRequestError) as exc_info:
67+
Transfer.from_dict(_api_response(asset=False))
68+
assert exc_info.value.param == "asset"
69+
70+
71+
def test_status_is_transfer_status_enum():
72+
for raw in ("pending", "processing", "completed", "failed"):
73+
transfer = Transfer.from_dict(_api_response(status=raw))
74+
assert isinstance(transfer.status, TransferStatus)
75+
assert transfer.status.value == raw
76+
77+
78+
def test_invalid_status_raises():
79+
with pytest.raises(InvalidRequestError) as exc_info:
80+
Transfer.from_dict(_api_response(status="bogus"))
81+
assert exc_info.value.param == "status"
82+
83+
84+
def test_completed_transfer_has_stellar_tx_hash():
85+
transfer = Transfer.from_dict(
86+
_api_response(status="completed", stellarTxHash="a" * 64)
87+
)
88+
assert transfer.status is TransferStatus.COMPLETED
89+
assert transfer.stellar_tx_hash == "a" * 64
90+
91+
92+
def test_fee_defaults_to_none():
93+
payload = _api_response()
94+
del payload["fee"]
95+
transfer = Transfer.from_dict(payload)
96+
assert transfer.fee is None
97+
98+
99+
def test_transfer_is_exported_from_package():
100+
assert shade.Transfer is Transfer
101+
assert shade.TransferStatus is TransferStatus
102+
assert issubclass(Transfer, ShadeObject)
103+
104+
105+
def test_from_dict_requires_a_mapping():
106+
with pytest.raises(InvalidRequestError):
107+
Transfer.from_dict([("id", "x")]) # type: ignore[arg-type]
108+
109+
110+
def test_missing_id_raises_clear_validation_error():
111+
payload = _api_response()
112+
del payload["id"]
113+
with pytest.raises(InvalidRequestError) as exc_info:
114+
Transfer.from_dict(payload)
115+
assert exc_info.value.param == "id"
116+
117+
118+
def test_invalid_source_address_is_rejected():
119+
with pytest.raises(InvalidRequestError) as exc_info:
120+
Transfer.from_dict(_api_response(sourceAddress="not-a-stellar-key"))
121+
assert exc_info.value.param == "sourceAddress"
122+
123+
124+
def test_invalid_destination_address_is_rejected():
125+
with pytest.raises(InvalidRequestError) as exc_info:
126+
Transfer.from_dict(_api_response(destinationAddress="not-a-stellar-key"))
127+
assert exc_info.value.param == "destinationAddress"
128+
129+
130+
def test_non_positive_amount_is_rejected():
131+
with pytest.raises(InvalidRequestError) as exc_info:
132+
Transfer.from_dict(_api_response(amount="0"))
133+
assert exc_info.value.param == "amount"
134+
135+
136+
def test_negative_fee_is_rejected():
137+
with pytest.raises(InvalidRequestError) as exc_info:
138+
Transfer.from_dict(_api_response(fee="-1"))
139+
assert exc_info.value.param == "fee"
140+
141+
142+
def test_from_dict_preserves_unknown_keys():
143+
transfer = Transfer.from_dict(_api_response(description="Payout for order #9"))
144+
assert transfer.to_dict()["description"] == "Payout for order #9"
145+
146+
147+
def test_to_dict_round_trips_to_camelcase():
148+
payload = _api_response()
149+
transfer = Transfer.from_dict(payload)
150+
round_tripped = transfer.to_dict()
151+
assert round_tripped["sourceAddress"] == SOURCE_ADDRESS
152+
assert round_tripped["destinationAddress"] == DESTINATION_ADDRESS
153+
assert round_tripped["stellarTxHash"] is None
154+
assert Transfer.from_dict(round_tripped) == transfer

0 commit comments

Comments
 (0)