From 8298b2a42ac9479c3d8ecdb576291dd487d5d160 Mon Sep 17 00:00:00 2001 From: Sainath More Date: Tue, 21 Jul 2026 04:32:17 +0000 Subject: [PATCH] Add Payment model with Decimal amounts and PaymentStatus. Introduce ShadeObject base for from_dict/to_dict, add Payment and PaymentStatus (str, Enum for Python 3.10), validate amount > 0, declare pydantic in pyproject.toml, and cover the model with unit tests. --- poetry.lock | 2 +- pyproject.toml | 1 + src/shade/models/__init__.py | 10 +++ src/shade/models/base.py | 29 +++++++++ src/shade/models/payment.py | 43 +++++++++++++ tests/models/test_payment.py | 114 +++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/shade/models/__init__.py create mode 100644 src/shade/models/base.py create mode 100644 src/shade/models/payment.py create mode 100644 tests/models/test_payment.py diff --git a/poetry.lock b/poetry.lock index cbb437f..9923835 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1769,4 +1769,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "2aafbf3a0b56e50586010064755ac3151bafde9f644a4534af18964a97622287" +content-hash = "cf146d675350d7f14dc625876a98f43d3bd75caffa457a648740151bf79f6f70" diff --git a/pyproject.toml b/pyproject.toml index 6dc4155..ec3ae05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ packages = [{include = "shade", from = "src"}] [tool.poetry.dependencies] python = "^3.10" httpx = "^0.28.1" +pydantic = "^2.0" stellar-sdk = "^13.2.1" [tool.poetry.group.dev.dependencies] diff --git a/src/shade/models/__init__.py b/src/shade/models/__init__.py new file mode 100644 index 0000000..f2a43cc --- /dev/null +++ b/src/shade/models/__init__.py @@ -0,0 +1,10 @@ +"""Typed response models for the Shade API.""" + +from .base import ShadeObject +from .payment import Payment, PaymentStatus + +__all__ = [ + "Payment", + "PaymentStatus", + "ShadeObject", +] diff --git a/src/shade/models/base.py b/src/shade/models/base.py new file mode 100644 index 0000000..8b517f0 --- /dev/null +++ b/src/shade/models/base.py @@ -0,0 +1,29 @@ +"""Base model for Shade API response objects.""" +from __future__ import annotations + +from typing import Any, TypeVar + +from pydantic import BaseModel, ConfigDict + +_T = TypeVar("_T", bound="ShadeObject") + + +class ShadeObject(BaseModel): + """Shared base for typed API response models.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + def to_dict(self) -> dict[str, Any]: + """Serialize the model to a plain dictionary.""" + return self.model_dump() + + @classmethod + def from_dict(cls: type[_T], data: dict[str, Any]) -> _T: + """Construct a model instance from an API response dictionary.""" + return cls.model_validate(data) + + def __repr__(self) -> str: + object_id = getattr(self, "id", None) + if object_id is not None: + return f"<{self.__class__.__name__} id={object_id!r}>" + return f"<{self.__class__.__name__}>" diff --git a/src/shade/models/payment.py b/src/shade/models/payment.py new file mode 100644 index 0000000..7fd6673 --- /dev/null +++ b/src/shade/models/payment.py @@ -0,0 +1,43 @@ +"""Payment resource model.""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import Optional + +from pydantic import field_validator + +from .base import ShadeObject + + +class PaymentStatus(str, Enum): + """Lifecycle status of a Shade payment.""" + + PENDING = "pending" + COMPLETED = "completed" + CANCELLED = "cancelled" + EXPIRED = "expired" + PARTIALLY_PAID = "partially_paid" + + +class Payment(ShadeObject): + """Represents a Shade payment resource returned by the API.""" + + id: str + status: PaymentStatus + amount: Decimal + currency: str + description: Optional[str] = None + merchant_id: str + stellar_tx_hash: Optional[str] = None + payment_address: str + created_at: datetime + updated_at: datetime + + @field_validator("amount") + @classmethod + def amount_must_be_positive(cls, value: Decimal) -> Decimal: + if value <= 0: + raise ValueError("amount must be greater than 0") + return value diff --git a/tests/models/test_payment.py b/tests/models/test_payment.py new file mode 100644 index 0000000..75e9417 --- /dev/null +++ b/tests/models/test_payment.py @@ -0,0 +1,114 @@ +"""Tests for the Payment model.""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from shade.models import Payment, PaymentStatus + + +def _payment_payload(**overrides): + data = { + "id": "pay_123", + "status": "pending", + "amount": "100.50", + "currency": "XLM", + "description": "Order #42", + "merchant_id": "merch_abc", + "stellar_tx_hash": None, + "payment_address": "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + "created_at": "2024-01-15T12:00:00Z", + "updated_at": "2024-01-15T12:05:00Z", + } + data.update(overrides) + return data + + +def test_payment_from_dict_constructs_without_error(): + payment = Payment.from_dict(_payment_payload()) + + assert payment.id == "pay_123" + assert payment.currency == "XLM" + assert payment.merchant_id == "merch_abc" + assert payment.payment_address == "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + assert payment.description == "Order #42" + assert payment.stellar_tx_hash is None + assert isinstance(payment.created_at, datetime) + assert isinstance(payment.updated_at, datetime) + + +def test_payment_amount_is_decimal_not_float(): + payment = Payment.from_dict(_payment_payload(amount="100.50")) + + assert isinstance(payment.amount, Decimal) + assert not isinstance(payment.amount, float) + assert payment.amount == Decimal("100.50") + + +def test_payment_status_is_enum(): + payment = Payment.from_dict(_payment_payload(status="pending")) + + assert isinstance(payment.status, PaymentStatus) + assert payment.status is PaymentStatus.PENDING + assert payment.status == "pending" + + +@pytest.mark.parametrize( + "status", + [ + PaymentStatus.PENDING, + PaymentStatus.COMPLETED, + PaymentStatus.CANCELLED, + PaymentStatus.EXPIRED, + PaymentStatus.PARTIALLY_PAID, + ], +) +def test_payment_accepts_all_status_values(status): + payment = Payment.from_dict(_payment_payload(status=status.value)) + assert payment.status is status + + +def test_invalid_status_raises_clear_validation_error(): + with pytest.raises(ValidationError) as exc_info: + Payment.from_dict(_payment_payload(status="not_a_real_status")) + + errors = exc_info.value.errors() + assert any(err["loc"] == ("status",) for err in errors) + + +@pytest.mark.parametrize("amount", [0, -5, "0", "-1.00", Decimal("0")]) +def test_amount_must_be_positive(amount): + with pytest.raises(ValidationError) as exc_info: + Payment.from_dict(_payment_payload(amount=amount)) + + assert "amount" in str(exc_info.value).lower() + + +def test_optional_fields_default_to_none(): + payload = _payment_payload() + del payload["description"] + del payload["stellar_tx_hash"] + + payment = Payment.from_dict(payload) + + assert payment.description is None + assert payment.stellar_tx_hash is None + + +def test_extra_api_fields_are_allowed(): + payment = Payment.from_dict(_payment_payload(metadata={"order_id": "42"})) + + assert payment.id == "pay_123" + assert payment.metadata == {"order_id": "42"} + + +def test_payment_round_trip_preserves_decimal(): + payment = Payment.from_dict(_payment_payload(amount="0.0000001")) + restored = Payment.from_dict(payment.to_dict()) + + assert restored.amount == Decimal("0.0000001") + assert restored.status is PaymentStatus.PENDING + assert restored.id == payment.id