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
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"]
81 changes: 81 additions & 0 deletions src/shade/models/merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
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
first_name = (self.first_name or "").strip()
last_name = (self.last_name or "").strip()
full_name = " ".join(part for part in (first_name, last_name) if part)
if full_name:
return full_name
return (self.email or "").strip() or None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
187 changes: 187 additions & 0 deletions tests/test_merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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_display_name_normalizes_padded_name_components():
# Each component is stripped before joining, so padding does not leak into
# the middle of the full name as a double space.
merchant = Merchant.from_dict(
_api_response(businessName=None, firstName=" Ada ", lastName=" Lovelace ")
)
assert merchant.display_name == "Ada Lovelace"


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