Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
RateLimitError,
ShadeError,
)
from .models import ShadeObject
from .models import Merchant, ShadeObject

__version__ = "0.1.0"

Expand All @@ -29,6 +29,7 @@
"Gateway",
"HTTPError",
"InvalidRequestError",
"Merchant",
"NetworkError",
"NotFoundError",
"RateLimitError",
Expand Down
Empty file removed src/shade/base.py
Empty file.
Empty file removed src/shade/merchant.py
Empty file.
3 changes: 2 additions & 1 deletion src/shade/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
Shade API response models.
"""
from .base import ShadeObject
from .merchant import Merchant

__all__ = ["ShadeObject"]
__all__ = ["Merchant", "ShadeObject"]
79 changes: 79 additions & 0 deletions src/shade/models/merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
Merchant model.

Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names
converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python) via
pydantic field aliases. The :attr:`Merchant.merchant_id` field (from Prisma
``merchantId: Int``) is the numeric identifier the Soroban contract stamps onto
every invoice, making it the bridge between the backend and the on-chain world.
"""
from __future__ import annotations

from typing import Optional

from pydantic import Field, StrictBool, field_validator
from stellar_sdk.strkey import StrKey

from .base import ShadeObject


class Merchant(ShadeObject):
"""A Shade merchant account.

Build one from an API response with :meth:`ShadeObject.from_dict`, which maps
camelCase JSON keys to the snake_case fields below. ``address`` must be a
valid Stellar ed25519 public key and ``active`` / ``verified`` must be real
booleans; anything else raises
:class:`~shade.errors.InvalidRequestError` on construction.
"""

id: str
merchant_id: int = Field(alias="merchantId")
address: str
active: StrictBool
verified: StrictBool
account: Optional[str] = None
email: Optional[str] = None
first_name: Optional[str] = Field(default=None, alias="firstName")
last_name: Optional[str] = Field(default=None, alias="lastName")
business_name: Optional[str] = Field(default=None, alias="businessName")
category: Optional[str] = None
description: Optional[str] = None
logo: Optional[str] = None
webhook: Optional[str] = None

@field_validator("merchant_id", mode="before")
@classmethod
def _reject_bool_merchant_id(cls, value: object) -> object:
# pydantic would otherwise coerce ``True``/``False`` to 1/0; a boolean is
# never a valid merchant id, so reject it rather than silently accept it.
if isinstance(value, bool):
raise ValueError("merchant_id must be an integer, not a boolean")
return value

@field_validator("address")
@classmethod
def _validate_address(cls, value: str) -> str:
if not StrKey.is_valid_ed25519_public_key(value):
raise ValueError(
"address must be a valid Stellar public key "
"(starts with 'G', 56 characters)"
)
return value

@property
def display_name(self) -> Optional[str]:
"""The most informative human-readable name available.

Prefers ``business_name``; falls back to the person's full name
(``"{first_name} {last_name}"``); finally ``email``. Each candidate is
trimmed, so a blank or whitespace-only value falls through to the next
one rather than being returned. ``None`` when nothing is available.
"""
business_name = (self.business_name or "").strip()
if business_name:
return business_name
full_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
if full_name:
return full_name
return (self.email or "").strip() or None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
178 changes: 178 additions & 0 deletions tests/test_merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import pytest
from stellar_sdk import Keypair

import shade
from shade import InvalidRequestError, Merchant, ShadeObject

VALID_ADDRESS = Keypair.random().public_key


def _api_response(**overrides):
"""A representative camelCase backend payload."""
data = {
"id": "clx123",
"merchantId": 42,
"address": VALID_ADDRESS,
"account": "GACCOUNT",
"email": "owner@acme.test",
"firstName": "Ada",
"lastName": "Lovelace",
"businessName": "Acme Payments",
"category": "software",
"description": "We take money.",
"logo": "https://cdn.test/logo.png",
"webhook": "https://acme.test/hooks",
"active": True,
"verified": True,
}
data.update(overrides)
return data


def test_from_dict_maps_camelcase_to_snake_case():
merchant = Merchant.from_dict(_api_response())

assert merchant.id == "clx123"
assert merchant.merchant_id == 42
assert merchant.address == VALID_ADDRESS
assert merchant.first_name == "Ada"
assert merchant.last_name == "Lovelace"
assert merchant.business_name == "Acme Payments"
assert merchant.active is True
assert merchant.verified is True


def test_merchant_id_is_int():
merchant = Merchant.from_dict(_api_response(merchantId=7))
assert isinstance(merchant.merchant_id, int)
assert merchant.merchant_id == 7


def test_merchant_is_exported_from_package():
assert shade.Merchant is Merchant
assert issubclass(Merchant, ShadeObject)


def test_from_dict_preserves_unknown_keys():
# The ShadeObject base allows extra fields so a server-side addition never
# breaks an older SDK; the known fields still map correctly.
merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01"))
assert merchant.merchant_id == 42
assert merchant.to_dict()["createdAt"] == "2026-01-01"


def test_from_dict_requires_a_mapping():
with pytest.raises(InvalidRequestError):
Merchant.from_dict([("id", "x")]) # type: ignore[arg-type]


def test_invalid_address_raises_on_construction():
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(address="not-a-stellar-key"))
assert exc_info.value.param == "address"


def test_address_wrong_length_is_rejected():
with pytest.raises(InvalidRequestError):
Merchant(
id="x",
merchant_id=1,
address="G" + "A" * 55, # starts with G but too short / bad checksum
active=True,
verified=False,
)


def test_non_integer_merchant_id_raises():
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(merchantId="abc"))
assert exc_info.value.param == "merchantId"


def test_boolean_merchant_id_is_rejected():
# A bool would otherwise be coerced to 1/0; it is never a valid merchant id.
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(merchantId=True))
assert exc_info.value.param == "merchantId"


@pytest.mark.parametrize("field", ["active", "verified"])
@pytest.mark.parametrize("value", ["false", "true", "", 0, 1, None])
def test_non_boolean_flags_are_rejected(field, value):
"""Strings like "false" must not be silently coerced to True."""
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(**{field: value}))
assert exc_info.value.param == field


def test_boolean_flags_are_preserved():
merchant = Merchant.from_dict(_api_response(active=False, verified=True))
assert merchant.active is False
assert merchant.verified is True


def test_display_name_prefers_business_name():
merchant = Merchant.from_dict(_api_response())
assert merchant.display_name == "Acme Payments"


def test_display_name_falls_back_to_full_name():
merchant = Merchant.from_dict(_api_response(businessName=None))
assert merchant.display_name == "Ada Lovelace"


def test_display_name_trims_missing_last_name():
merchant = Merchant.from_dict(_api_response(businessName=None, lastName=None))
assert merchant.display_name == "Ada"


def test_display_name_falls_back_to_email():
merchant = Merchant.from_dict(
_api_response(businessName=None, firstName=None, lastName=None)
)
assert merchant.display_name == "owner@acme.test"


def test_display_name_ignores_whitespace_only_business_name():
"""A blank business_name must fall through, not be returned verbatim."""
merchant = Merchant.from_dict(_api_response(businessName=" "))
assert merchant.display_name == "Ada Lovelace"


def test_display_name_ignores_whitespace_only_names():
merchant = Merchant.from_dict(
_api_response(businessName="", firstName=" ", lastName="\t")
)
assert merchant.display_name == "owner@acme.test"


def test_display_name_is_none_when_every_candidate_is_blank():
merchant = Merchant.from_dict(
_api_response(businessName=" ", firstName="", lastName=None, email=" ")
)
assert merchant.display_name is None


def test_display_name_trims_the_returned_value():
merchant = Merchant.from_dict(_api_response(businessName=" Acme Payments "))
assert merchant.display_name == "Acme Payments"


def test_optional_fields_default_to_none():
merchant = Merchant(
id="x",
merchant_id=1,
address=VALID_ADDRESS,
active=False,
verified=False,
)
assert merchant.account is None
assert merchant.email is None
assert merchant.display_name is None


def test_to_dict_round_trips_to_camelcase():
payload = _api_response()
merchant = Merchant.from_dict(payload)
assert merchant.to_dict() == payload
assert Merchant.from_dict(merchant.to_dict()) == merchant
Loading