Skip to content

Commit c6a28ab

Browse files
authored
Merge pull request ShadeProtocol#51 from Yourbigmike/feat/balance-model
feat(models): add AssetBalance and Balance models for per-asset balan…
2 parents 306cf79 + 0a50f8d commit c6a28ab

4 files changed

Lines changed: 229 additions & 2 deletions

File tree

src/shade/__init__.py

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

2121
__version__ = "0.1.0"
2222

2323
# ShadeClient is an alias for Gateway.
2424
ShadeClient = Gateway
2525

2626
__all__ = [
27+
"AssetBalance",
2728
"AsyncHTTPClient",
2829
"AuthenticationError",
30+
"Balance",
2931
"Environment",
3032
"Gateway",
3133
"HTTPError",

src/shade/models/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
"""
22
Shade API response models.
33
"""
4+
from .balance import AssetBalance, Balance
45
from .base import ShadeObject
56
from .merchant import Merchant
67
from .transfer import Transfer, TransferStatus
78

8-
__all__ = ["Merchant", "ShadeObject", "Transfer", "TransferStatus"]
9+
__all__ = [
10+
"AssetBalance",
11+
"Balance",
12+
"Merchant",
13+
"ShadeObject",
14+
"Transfer",
15+
"TransferStatus",
16+
]

src/shade/models/balance.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
Balance models.
3+
4+
Represents the per-asset balance breakdown returned by
5+
:meth:`Merchant.get_balance`. A merchant wallet may hold multiple Stellar
6+
assets (native XLM, USDC, etc.), each described by an
7+
:class:`AssetBalance` entry.
8+
"""
9+
from __future__ import annotations
10+
11+
from decimal import Decimal
12+
from typing import List, Optional
13+
14+
from pydantic import Field
15+
16+
from .base import ShadeObject
17+
18+
19+
class AssetBalance(ShadeObject):
20+
"""Balance for a single Stellar asset.
21+
22+
``asset_code`` is ``"XLM"`` for the native balance. For issued assets
23+
``asset_issuer`` contains the issuing account's public key; it is
24+
``None`` for the native asset.
25+
"""
26+
27+
_id_field = "asset_code"
28+
29+
asset_code: str = Field(alias="assetCode")
30+
asset_issuer: Optional[str] = Field(default=None, alias="assetIssuer")
31+
balance: Decimal
32+
33+
34+
class Balance(ShadeObject):
35+
"""Collection of per-asset balances for a merchant wallet.
36+
37+
The ``balances`` list is populated from the ``balances`` key in the API
38+
response. Use :meth:`get` to look up a specific asset by code.
39+
"""
40+
41+
_id_field = "id"
42+
43+
id: Optional[str] = None
44+
merchant_id: Optional[str] = Field(default=None, alias="merchantId")
45+
balances: List[AssetBalance] = []
46+
47+
def get(self, asset_code: str) -> Optional[AssetBalance]:
48+
"""Return the :class:`AssetBalance` for *asset_code*, or ``None``.
49+
50+
Examples::
51+
52+
balance.get("XLM") # native balance
53+
balance.get("USDC") # USDC balance if held
54+
balance.get("NOTEXIST") # None — no KeyError
55+
"""
56+
code = asset_code.upper()
57+
for entry in self.balances:
58+
if entry.asset_code.upper() == code:
59+
return entry
60+
return None

tests/test_balance.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from decimal import Decimal
2+
3+
import pytest
4+
5+
import shade
6+
from shade import AssetBalance, Balance, InvalidRequestError, ShadeObject
7+
8+
9+
def _asset_balance(**overrides):
10+
data = {
11+
"assetCode": "XLM",
12+
"assetIssuer": None,
13+
"balance": "100.00",
14+
}
15+
data.update(overrides)
16+
return data
17+
18+
19+
def _api_response(**overrides):
20+
data = {
21+
"id": "bal_123",
22+
"merchantId": "merch_456",
23+
"balances": [
24+
_asset_balance(assetCode="XLM", assetIssuer=None, balance="100.00"),
25+
_asset_balance(assetCode="USDC", assetIssuer="GBBD47CC6KON37" + "X" * 42, balance="250.50"),
26+
],
27+
}
28+
data.update(overrides)
29+
return data
30+
31+
32+
# ── AssetBalance ──────────────────────────────────────────────────────────
33+
34+
35+
def test_asset_balance_from_dict_maps_camelcase():
36+
ab = AssetBalance.from_dict(_asset_balance(assetCode="USDC", assetIssuer="GABC"))
37+
assert ab.asset_code == "USDC"
38+
assert ab.asset_issuer == "GABC"
39+
assert ab.balance == Decimal("100.00")
40+
41+
42+
def test_asset_balance_native_has_no_issuer():
43+
ab = AssetBalance.from_dict(_asset_balance())
44+
assert ab.asset_code == "XLM"
45+
assert ab.asset_issuer is None
46+
47+
48+
def test_asset_balance_balance_is_decimal():
49+
ab = AssetBalance.from_dict(_asset_balance(balance="99.99"))
50+
assert isinstance(ab.balance, Decimal)
51+
assert ab.balance == Decimal("99.99")
52+
53+
54+
def test_asset_balance_preserves_unknown_keys():
55+
ab = AssetBalance.from_dict(_asset_balance(trustlineLimit="1000"))
56+
assert ab.to_dict()["trustlineLimit"] == "1000"
57+
58+
59+
def test_asset_balance_is_shade_object():
60+
assert issubclass(AssetBalance, ShadeObject)
61+
62+
63+
def test_asset_balance_repr_uses_asset_code():
64+
ab = AssetBalance.from_dict(_asset_balance())
65+
assert "asset_code=" in repr(ab)
66+
assert "XLM" in repr(ab)
67+
68+
69+
# ── Balance ───────────────────────────────────────────────────────────────
70+
71+
72+
def test_balance_from_dict_maps_camelcase():
73+
balance = Balance.from_dict(_api_response())
74+
assert balance.id == "bal_123"
75+
assert balance.merchant_id == "merch_456"
76+
assert len(balance.balances) == 2
77+
78+
79+
def test_balance_balances_are_asset_balance_instances():
80+
balance = Balance.from_dict(_api_response())
81+
for entry in balance.balances:
82+
assert isinstance(entry, AssetBalance)
83+
84+
85+
def test_balance_amounts_are_decimal():
86+
balance = Balance.from_dict(_api_response())
87+
for entry in balance.balances:
88+
assert isinstance(entry.balance, Decimal)
89+
90+
91+
def test_balance_get_returns_matching_asset():
92+
balance = Balance.from_dict(_api_response())
93+
xlm = balance.get("XLM")
94+
assert xlm is not None
95+
assert xlm.asset_code == "XLM"
96+
assert xlm.balance == Decimal("100.00")
97+
98+
99+
def test_balance_get_returns_usdc():
100+
balance = Balance.from_dict(_api_response())
101+
usdc = balance.get("USDC")
102+
assert usdc is not None
103+
assert usdc.asset_code == "USDC"
104+
assert usdc.balance == Decimal("250.50")
105+
106+
107+
def test_balance_get_is_case_insensitive():
108+
balance = Balance.from_dict(_api_response())
109+
assert balance.get("xlm") is not None
110+
assert balance.get("usdc") is not None
111+
112+
113+
def test_balance_get_returns_none_for_missing_asset():
114+
balance = Balance.from_dict(_api_response())
115+
assert balance.get("NOTEXIST") is None
116+
117+
118+
def test_balance_get_returns_none_for_empty_balances():
119+
balance = Balance.from_dict(_api_response(balances=[]))
120+
assert balance.get("XLM") is None
121+
122+
123+
def test_balance_optional_fields_default_to_none():
124+
payload = {"balances": [_asset_balance()]}
125+
balance = Balance.from_dict(payload)
126+
assert balance.id is None
127+
assert balance.merchant_id is None
128+
129+
130+
def test_balance_is_exported_from_package():
131+
assert shade.Balance is Balance
132+
assert shade.AssetBalance is AssetBalance
133+
134+
135+
def test_balance_preserves_unknown_keys():
136+
balance = Balance.from_dict(_api_response(metadata="test"))
137+
assert balance.to_dict()["metadata"] == "test"
138+
139+
140+
def test_balance_to_dict_round_trips():
141+
payload = _api_response()
142+
balance = Balance.from_dict(payload)
143+
round_tripped = balance.to_dict()
144+
rebalanced = Balance.from_dict(round_tripped)
145+
assert rebalanced.id == balance.id
146+
assert rebalanced.merchant_id == balance.merchant_id
147+
assert len(rebalanced.balances) == len(balance.balances)
148+
149+
150+
def test_balance_repr_uses_id():
151+
balance = Balance.from_dict(_api_response())
152+
assert "bal_123" in repr(balance)
153+
154+
155+
def test_balance_repr_without_id():
156+
balance = Balance.from_dict({"balances": [_asset_balance()]})
157+
assert repr(balance) == "<Balance>"

0 commit comments

Comments
 (0)