diff --git a/pyproject.toml b/pyproject.toml index 02a31a3..1d473b3 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" pydantic = "^2.0" diff --git a/src/shade/models/__init__.py b/src/shade/models/__init__.py index 1e70fb5..48ed11d 100644 --- a/src/shade/models/__init__.py +++ b/src/shade/models/__init__.py @@ -4,6 +4,7 @@ from .balance import AssetBalance, Balance from .base import ShadeObject from .merchant import Merchant +from .payment import Payment, PaymentStatus from .transfer import Transfer, TransferStatus from .webhook import WebhookEvent, WebhookEventType @@ -11,6 +12,8 @@ "AssetBalance", "Balance", "Merchant", + "Payment", + "PaymentStatus", "ShadeObject", "Transfer", "TransferStatus", 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