-
Notifications
You must be signed in to change notification settings - Fork 17
feat: implement Merchant model (Closes #39) #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
codebestia
merged 6 commits into
ShadeProtocol:main
from
giftexceed:feat/39-merchant-model
Jul 24, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ff6c8da
feat: implement Merchant model mirroring backend Prisma schema
giftexceed df8876d
fix: reject non-boolean active/verified and suppress A002 on id
giftexceed 8a802b7
fix: ignore whitespace-only candidates in display_name
giftexceed 549cf75
Merge remote-tracking branch 'upstream/main' into feat/39-merchant-model
giftexceed 27304be
refactor: move Merchant into models/ on the pydantic ShadeObject base
giftexceed 9530f3b
fix: strip name components before joining in display_name
giftexceed File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.