Skip to content

Commit 7652f14

Browse files
authored
Merge pull request #45 from giftexceed/feat/39-merchant-model
feat: implement Merchant model (Closes #39)
2 parents 1704937 + 9530f3b commit 7652f14

6 files changed

Lines changed: 272 additions & 2 deletions

File tree

src/shade/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
RateLimitError,
1616
ShadeError,
1717
)
18-
from .models import ShadeObject
18+
from .models import Merchant, ShadeObject
1919

2020
__version__ = "0.1.0"
2121

@@ -29,6 +29,7 @@
2929
"Gateway",
3030
"HTTPError",
3131
"InvalidRequestError",
32+
"Merchant",
3233
"NetworkError",
3334
"NotFoundError",
3435
"RateLimitError",

src/shade/base.py

Whitespace-only changes.

src/shade/merchant.py

Whitespace-only changes.

src/shade/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
Shade API response models.
33
"""
44
from .base import ShadeObject
5+
from .merchant import Merchant
56

6-
__all__ = ["ShadeObject"]
7+
__all__ = ["Merchant", "ShadeObject"]

src/shade/models/merchant.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Merchant model.
3+
4+
Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names
5+
converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python) via
6+
pydantic field aliases. The :attr:`Merchant.merchant_id` field (from Prisma
7+
``merchantId: Int``) is the numeric identifier the Soroban contract stamps onto
8+
every invoice, making it the bridge between the backend and the on-chain world.
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import Optional
13+
14+
from pydantic import Field, StrictBool, field_validator
15+
from stellar_sdk.strkey import StrKey
16+
17+
from .base import ShadeObject
18+
19+
20+
class Merchant(ShadeObject):
21+
"""A Shade merchant account.
22+
23+
Build one from an API response with :meth:`ShadeObject.from_dict`, which maps
24+
camelCase JSON keys to the snake_case fields below. ``address`` must be a
25+
valid Stellar ed25519 public key and ``active`` / ``verified`` must be real
26+
booleans; anything else raises
27+
:class:`~shade.errors.InvalidRequestError` on construction.
28+
"""
29+
30+
id: str
31+
merchant_id: int = Field(alias="merchantId")
32+
address: str
33+
active: StrictBool
34+
verified: StrictBool
35+
account: Optional[str] = None
36+
email: Optional[str] = None
37+
first_name: Optional[str] = Field(default=None, alias="firstName")
38+
last_name: Optional[str] = Field(default=None, alias="lastName")
39+
business_name: Optional[str] = Field(default=None, alias="businessName")
40+
category: Optional[str] = None
41+
description: Optional[str] = None
42+
logo: Optional[str] = None
43+
webhook: Optional[str] = None
44+
45+
@field_validator("merchant_id", mode="before")
46+
@classmethod
47+
def _reject_bool_merchant_id(cls, value: object) -> object:
48+
# pydantic would otherwise coerce ``True``/``False`` to 1/0; a boolean is
49+
# never a valid merchant id, so reject it rather than silently accept it.
50+
if isinstance(value, bool):
51+
raise ValueError("merchant_id must be an integer, not a boolean")
52+
return value
53+
54+
@field_validator("address")
55+
@classmethod
56+
def _validate_address(cls, value: str) -> str:
57+
if not StrKey.is_valid_ed25519_public_key(value):
58+
raise ValueError(
59+
"address must be a valid Stellar public key "
60+
"(starts with 'G', 56 characters)"
61+
)
62+
return value
63+
64+
@property
65+
def display_name(self) -> Optional[str]:
66+
"""The most informative human-readable name available.
67+
68+
Prefers ``business_name``; falls back to the person's full name
69+
(``"{first_name} {last_name}"``); finally ``email``. Each candidate is
70+
trimmed, so a blank or whitespace-only value falls through to the next
71+
one rather than being returned. ``None`` when nothing is available.
72+
"""
73+
business_name = (self.business_name or "").strip()
74+
if business_name:
75+
return business_name
76+
first_name = (self.first_name or "").strip()
77+
last_name = (self.last_name or "").strip()
78+
full_name = " ".join(part for part in (first_name, last_name) if part)
79+
if full_name:
80+
return full_name
81+
return (self.email or "").strip() or None

tests/test_merchant.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import pytest
2+
from stellar_sdk import Keypair
3+
4+
import shade
5+
from shade import InvalidRequestError, Merchant, ShadeObject
6+
7+
VALID_ADDRESS = Keypair.random().public_key
8+
9+
10+
def _api_response(**overrides):
11+
"""A representative camelCase backend payload."""
12+
data = {
13+
"id": "clx123",
14+
"merchantId": 42,
15+
"address": VALID_ADDRESS,
16+
"account": "GACCOUNT",
17+
"email": "owner@acme.test",
18+
"firstName": "Ada",
19+
"lastName": "Lovelace",
20+
"businessName": "Acme Payments",
21+
"category": "software",
22+
"description": "We take money.",
23+
"logo": "https://cdn.test/logo.png",
24+
"webhook": "https://acme.test/hooks",
25+
"active": True,
26+
"verified": True,
27+
}
28+
data.update(overrides)
29+
return data
30+
31+
32+
def test_from_dict_maps_camelcase_to_snake_case():
33+
merchant = Merchant.from_dict(_api_response())
34+
35+
assert merchant.id == "clx123"
36+
assert merchant.merchant_id == 42
37+
assert merchant.address == VALID_ADDRESS
38+
assert merchant.first_name == "Ada"
39+
assert merchant.last_name == "Lovelace"
40+
assert merchant.business_name == "Acme Payments"
41+
assert merchant.active is True
42+
assert merchant.verified is True
43+
44+
45+
def test_merchant_id_is_int():
46+
merchant = Merchant.from_dict(_api_response(merchantId=7))
47+
assert isinstance(merchant.merchant_id, int)
48+
assert merchant.merchant_id == 7
49+
50+
51+
def test_merchant_is_exported_from_package():
52+
assert shade.Merchant is Merchant
53+
assert issubclass(Merchant, ShadeObject)
54+
55+
56+
def test_from_dict_preserves_unknown_keys():
57+
# The ShadeObject base allows extra fields so a server-side addition never
58+
# breaks an older SDK; the known fields still map correctly.
59+
merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01"))
60+
assert merchant.merchant_id == 42
61+
assert merchant.to_dict()["createdAt"] == "2026-01-01"
62+
63+
64+
def test_from_dict_requires_a_mapping():
65+
with pytest.raises(InvalidRequestError):
66+
Merchant.from_dict([("id", "x")]) # type: ignore[arg-type]
67+
68+
69+
def test_invalid_address_raises_on_construction():
70+
with pytest.raises(InvalidRequestError) as exc_info:
71+
Merchant.from_dict(_api_response(address="not-a-stellar-key"))
72+
assert exc_info.value.param == "address"
73+
74+
75+
def test_address_wrong_length_is_rejected():
76+
with pytest.raises(InvalidRequestError):
77+
Merchant(
78+
id="x",
79+
merchant_id=1,
80+
address="G" + "A" * 55, # starts with G but too short / bad checksum
81+
active=True,
82+
verified=False,
83+
)
84+
85+
86+
def test_non_integer_merchant_id_raises():
87+
with pytest.raises(InvalidRequestError) as exc_info:
88+
Merchant.from_dict(_api_response(merchantId="abc"))
89+
assert exc_info.value.param == "merchantId"
90+
91+
92+
def test_boolean_merchant_id_is_rejected():
93+
# A bool would otherwise be coerced to 1/0; it is never a valid merchant id.
94+
with pytest.raises(InvalidRequestError) as exc_info:
95+
Merchant.from_dict(_api_response(merchantId=True))
96+
assert exc_info.value.param == "merchantId"
97+
98+
99+
@pytest.mark.parametrize("field", ["active", "verified"])
100+
@pytest.mark.parametrize("value", ["false", "true", "", 0, 1, None])
101+
def test_non_boolean_flags_are_rejected(field, value):
102+
"""Strings like "false" must not be silently coerced to True."""
103+
with pytest.raises(InvalidRequestError) as exc_info:
104+
Merchant.from_dict(_api_response(**{field: value}))
105+
assert exc_info.value.param == field
106+
107+
108+
def test_boolean_flags_are_preserved():
109+
merchant = Merchant.from_dict(_api_response(active=False, verified=True))
110+
assert merchant.active is False
111+
assert merchant.verified is True
112+
113+
114+
def test_display_name_prefers_business_name():
115+
merchant = Merchant.from_dict(_api_response())
116+
assert merchant.display_name == "Acme Payments"
117+
118+
119+
def test_display_name_falls_back_to_full_name():
120+
merchant = Merchant.from_dict(_api_response(businessName=None))
121+
assert merchant.display_name == "Ada Lovelace"
122+
123+
124+
def test_display_name_trims_missing_last_name():
125+
merchant = Merchant.from_dict(_api_response(businessName=None, lastName=None))
126+
assert merchant.display_name == "Ada"
127+
128+
129+
def test_display_name_falls_back_to_email():
130+
merchant = Merchant.from_dict(
131+
_api_response(businessName=None, firstName=None, lastName=None)
132+
)
133+
assert merchant.display_name == "owner@acme.test"
134+
135+
136+
def test_display_name_ignores_whitespace_only_business_name():
137+
"""A blank business_name must fall through, not be returned verbatim."""
138+
merchant = Merchant.from_dict(_api_response(businessName=" "))
139+
assert merchant.display_name == "Ada Lovelace"
140+
141+
142+
def test_display_name_ignores_whitespace_only_names():
143+
merchant = Merchant.from_dict(
144+
_api_response(businessName="", firstName=" ", lastName="\t")
145+
)
146+
assert merchant.display_name == "owner@acme.test"
147+
148+
149+
def test_display_name_is_none_when_every_candidate_is_blank():
150+
merchant = Merchant.from_dict(
151+
_api_response(businessName=" ", firstName="", lastName=None, email=" ")
152+
)
153+
assert merchant.display_name is None
154+
155+
156+
def test_display_name_trims_the_returned_value():
157+
merchant = Merchant.from_dict(_api_response(businessName=" Acme Payments "))
158+
assert merchant.display_name == "Acme Payments"
159+
160+
161+
def test_display_name_normalizes_padded_name_components():
162+
# Each component is stripped before joining, so padding does not leak into
163+
# the middle of the full name as a double space.
164+
merchant = Merchant.from_dict(
165+
_api_response(businessName=None, firstName=" Ada ", lastName=" Lovelace ")
166+
)
167+
assert merchant.display_name == "Ada Lovelace"
168+
169+
170+
def test_optional_fields_default_to_none():
171+
merchant = Merchant(
172+
id="x",
173+
merchant_id=1,
174+
address=VALID_ADDRESS,
175+
active=False,
176+
verified=False,
177+
)
178+
assert merchant.account is None
179+
assert merchant.email is None
180+
assert merchant.display_name is None
181+
182+
183+
def test_to_dict_round_trips_to_camelcase():
184+
payload = _api_response()
185+
merchant = Merchant.from_dict(payload)
186+
assert merchant.to_dict() == payload
187+
assert Merchant.from_dict(merchant.to_dict()) == merchant

0 commit comments

Comments
 (0)